diff --git a/docs/development-workflow.md b/docs/development-workflow.md index b3ec30cfc..824375074 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -58,24 +58,27 @@ 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; @@ -83,8 +86,9 @@ 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 @@ -92,9 +96,11 @@ 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 diff --git a/package.json b/package.json index b22e9ea5b..39b667328 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-dev.ts b/scripts/run-dev.ts new file mode 100644 index 000000000..362ce0dbc --- /dev/null +++ b/scripts/run-dev.ts @@ -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((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 = {}; + 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; + }); +} diff --git a/tests/electron/development-launch.spec.ts b/tests/electron/development-launch.spec.ts new file mode 100644 index 000000000..cbc88ac3d --- /dev/null +++ b/tests/electron/development-launch.spec.ts @@ -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 }); } +}); diff --git a/tests/unit/feature-discovery.test.ts b/tests/unit/feature-discovery.test.ts index 713306ec4..459e28ca9 100644 --- a/tests/unit/feature-discovery.test.ts +++ b/tests/unit/feature-discovery.test.ts @@ -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/); +});