Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 21 additions & 15 deletions docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,43 +58,49 @@ changes can wait for one planned release.

Run from the intended worktree:

- `pnpm dev`: real app; uses the existing `Guild Wars` profile by default.
- `pnpm dev`: builds and verifies the real launcher; uses the existing `Guild Wars` profile by default.
- `pnpm dev:signed`: signed Dev package with persistent login; requires Apple signing configuration.
- `pnpm launcher:fixture fresh`: disposable offline launcher, not a playable client.
- `pnpm tools:dev`: standalone browser UI, not the application.

For isolated real-app checks, build successfully, then launch with an explicit
application path and disposable profile:
For isolated real-app checks:

```bash
pnpm build &&
dev_profile=$(mktemp -d "${TMPDIR:-/tmp}/gwonmac-dev.XXXXXX") &&
test -n "$dev_profile" &&
GW_BACKGROUND_LAUNCH=0 GW_EXPECT_USER_DATA="$dev_profile" \
pnpm exec electron "$PWD" --user-data-dir="$dev_profile"
pnpm dev --isolated
```

Reuse that profile during the task. It may download game data; saved login is
memory-only. Do not copy player data without authorization.
The command owns the app path and profile assertion. It prints `launcher-open`
only after verifying the actual process, checkout, profile and visible
`gw://app/launcher/index.html`. A welcome page, identity mismatch or startup
failure cannot produce that receipt. Launcher verification does not mean that
the game cache is ready or gameplay passed.

Keep the command running. After closing the app, reuse its printed profile with
`pnpm dev --profile /absolute/profile/path`. An already-running profile refuses
before building; preserve that session. The profile may download game data;
saved login is memory-only. Do not copy player data without authorization.

`GW_EXPECT_USER_DATA` checks the actual profile before client preparation.
Aliases such as `/var` and `/private/var` resolve to the same existing directory;
an empty, missing, dangling, or different expected path refuses preparation.
Do not remove this assertion to work around a profile mismatch.

For a cached-only check, first provision the authorized game artifacts and
chunks in that profile, then add `GW_REQUIRE_CACHED_CLIENT=1` to the launch.
Use `pnpm certification doctor --profile "$dev_profile"` to inspect readiness.
chunks in that profile, then use `pnpm dev --profile /absolute/profile/path --cached-only`.
Use `pnpm certification doctor --profile /absolute/profile/path` with that same
printed profile to inspect readiness.
Cache provisioning does not require copying account settings or credentials.
Keep logs, screenshots, and the profile path in ignored `test-results/`, outside
`build/`: the build command deletes that directory. Do not redirect into the
same file that a command is reading to recover its profile path.
Playwright suites own only `test-results/electron/` and `test-results/tools-e2e/`.
Keep development evidence and profiles outside those disposable suite directories.

Use `cua.getState()` for discovery. Confirm the process working directory and
profile, then attach through its debugger or exact running app path. App lookup
can launch bare Electron if the process is absent; its welcome page is not gwonmac.
For UI attachment, first confirm that the receipt's PID is still running with
the printed checkout and profile. Then use `cua.getState()` for discovery and
select the exact running app path. `cua.getApp()` starts an absent app: never
use it to launch or check whether Electron is running. If the process exited,
repeat the development command and wait for a new receipt before attaching.

Verify `gw://app/launcher/index.html` before handoff. Starting the game hides the
launcher; restore it with **Window → Show Launcher**. Do not click Play merely
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"scripts": {
"postinstall": "install-electron",
"build": "node scripts/build.mjs",
"dev": "pnpm build && electron-forge start",
"dev": "node --import ./scripts/ts-hook.mjs scripts/run-dev.ts",
"dev:signed": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts",
"launcher:fixture": "pnpm build && node --import ./scripts/ts-hook.mjs scripts/launcher-fixture.ts",
"test:signed-dev": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts --test-keychain",
Expand Down
121 changes: 121 additions & 0 deletions scripts/run-dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/** Launch the intended checkout and prove its launcher identity before handoff. */
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ElectronApplication } from "playwright";
import { defaultGuildWarsProfile } from "../src/tools/enhancement-workspace.js";
import { activeProfileOwner } from "./enhancements-live/profile-lock.js";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const launcherUrl = "gw://app/launcher/index.html";

export async function verifyDevelopmentApp(app: ElectronApplication, expectedRoot: string, expectedProfile: string) {
const identity = await app.evaluate(({ app }) => ({
root: app.getAppPath(), profile: app.getPath("userData"), pid: process.pid,
}));
if (await realpath(identity.root) !== await realpath(expectedRoot)
|| await realpath(identity.profile) !== await realpath(expectedProfile)) {
throw new Error("Development app identity mismatch; no launcher handoff.");
}
const page = await app.firstWindow({ timeout: 30_000 });
await page.waitForURL(launcherUrl, { timeout: 30_000 });
await page.waitForLoadState("domcontentloaded");
const visible = await app.evaluate(({ BrowserWindow }, url) => {
const window = BrowserWindow.getAllWindows().find((candidate) => candidate.webContents.getURL() === url);
if (!window) return false;
if (window.isVisible()) return true;
// DOM readiness can precede the native ready-to-show event. Observe the
// actual show event instead of declaring success early or racing a snapshot.
return new Promise<boolean>((resolve) => {
const shown = () => { clearTimeout(timeout); resolve(true); };
const timeout = setTimeout(() => {
window.removeListener("show", shown);
resolve(false);
}, 10_000);
window.once("show", shown);
});
}, launcherUrl);
if (!visible) throw new Error("Development launcher is not visible; no launcher handoff.");
return { ...identity, url: launcherUrl, status: "launcher-open" as const };
}

export async function launchDevelopmentApp(userData: string, cachedOnly: boolean) {
const profile = await realpath(userData);
const owner = await activeProfileOwner(profile);
if (owner !== null) {
throw new Error(`Profile already running (PID ${owner}); preserve that session instead of opening another app.`);
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
delete env.ELECTRON_RUN_AS_NODE;
Object.assign(env, {
GW_BACKGROUND_LAUNCH: "0", GW_EXPECT_USER_DATA: profile,
GW_REQUIRE_CACHED_CLIENT: cachedOnly ? "1" : "0",
});
const { _electron: electron } = await import("playwright");
const app = await electron.launch({
cwd: root, args: [root, `--user-data-dir=${profile}`], env,
timeout: 30_000,
});
try {
const receipt = await verifyDevelopmentApp(app, root, profile);
return { app, receipt };
} catch (error) {
await app.close();
throw error;
}
}

async function main() {
const args = process.argv.slice(2).filter((arg) => arg !== "--");
const usage = "usage: pnpm dev [--isolated | --profile PATH] [--cached-only]";
if (args.length === 1 && args[0] === "--help") { console.log(usage); return; }
let profileInput: string | undefined;
let isolated = false;
let cachedOnly = process.env.GW_REQUIRE_CACHED_CLIENT === "1";
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--isolated" && !isolated && profileInput === undefined) isolated = true;
else if (arg === "--profile" && !isolated && profileInput === undefined) {
const value = args[++i];
if (!value || value.startsWith("--")) throw new Error(usage);
profileInput = value;
} else if (arg === "--cached-only") cachedOnly = true;
else throw new Error(usage);
}
// Reject bad or active explicit profiles before paying for a build. Recheck
// ownership during launch because another process may start while building.
let profile = profileInput === undefined ? undefined : await realpath(profileInput);
if (!isolated) {
profile ??= defaultGuildWarsProfile();
const owner = await activeProfileOwner(profile);
if (owner !== null) throw new Error(`Profile already running (PID ${owner}); preserve that session.`);
}
const build = spawnSync(process.execPath, ["scripts/build.mjs"], {
cwd: root, stdio: "inherit",
});
if (build.error || build.status !== 0) throw new Error("Development build failed; app was not launched.");
profile ??= await mkdtemp(path.join(tmpdir(), "gwonmac-dev-"));
await mkdir(profile, { recursive: true });
const { app, receipt } = await launchDevelopmentApp(profile, cachedOnly);
// These are local developer coordinates, never production diagnostics.
console.log(JSON.stringify(receipt));
console.log("Launcher verified. Keep this command running. Reuse the printed profile on the next launch.");
const close = () => { void app.close(); };
process.once("SIGINT", close);
process.once("SIGTERM", close);
await app.waitForEvent("close", { timeout: 0 });
process.removeListener("SIGINT", close);
process.removeListener("SIGTERM", close);
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "Development launch failed.");
process.exitCode = 1;
});
}
33 changes: 33 additions & 0 deletions tests/electron/development-launch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { _electron as electron, test } from "@playwright/test";
import { launchDevelopmentApp, verifyDevelopmentApp } from "../../scripts/run-dev.js";
import { root } from "./fixtures.mjs";

test("development launch proves the real visible launcher and refuses a second profile owner", async () => {
const profile = await mkdtemp(path.join(tmpdir(), "gwonmac-dev-proof-"));
try {
const { app, receipt } = await launchDevelopmentApp(profile, true);
try {
assert.equal(receipt.root, root);
assert.equal(receipt.profile, await realpath(profile));
assert.equal(receipt.pid, app.process().pid);
assert.equal(receipt.url, "gw://app/launcher/index.html");
assert.equal(receipt.status, "launcher-open");
await assert.rejects(launchDevelopmentApp(profile, true), /Profile already running/);
assert.equal(app.process().exitCode, null, "refusal preserves the original app");
} finally { await app.close(); }
} finally { await rm(profile, { recursive: true, force: true }); }
});

test("bare Electron cannot be reported as the project launcher", async () => {
const profile = await mkdtemp(path.join(tmpdir(), "gwonmac-bare-electron-"));
try {
const app = await electron.launch({ args: [`--user-data-dir=${profile}`] });
try {
await assert.rejects(verifyDevelopmentApp(app, root, profile), /identity mismatch/);
} finally { await app.close(); }
} finally { await rm(profile, { recursive: true, force: true }); }
});
19 changes: 19 additions & 0 deletions tests/unit/feature-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,22 @@ test("live execution retains opt-in and cannot launch after a failed build", ()
assert.equal(failed.status, 9, failed.stderr);
assert.match(failed.stderr, /build failed/);
});

test("development launch rejects ambiguous profiles before build or Electron import", () => {
assert.equal(run("scripts/run-dev.ts", ["--help"]).status, 0);
for (const args of [["--profile"], ["--profile", ""], ["--isolated", "--profile", "/tmp"], ["--unknown"]]) {
const result = run("scripts/run-dev.ts", args);
assert.equal(result.status, 1);
assert.match(result.stderr, /usage:/);
}
const missing = run("scripts/run-dev.ts", ["--profile", path.join(temporary, "absent")]);
assert.equal(missing.status, 1);
assert.match(missing.stderr, /ENOENT/);
});

test("development launch cannot import Electron or report success after a failed build", () => {
const result = run("scripts/run-dev.ts", ["--isolated"], { DISCOVERY_TEST_BUILD_FAILURE: "1" });
assert.equal(result.status, 1);
assert.match(result.stderr, /build failed/);
assert.doesNotMatch(result.stdout, /launcher-open/);
});
Loading