diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index d242577..4a405e1 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -21,12 +21,13 @@ jobs: - run: pnpm install --frozen-lockfile - - run: pnpm build - - - run: pnpm --filter desktop build - - - name: Package (unsigned, dir-only) - run: pnpm --filter desktop package:dir - env: - # Skip code-signing in CI — we just want to validate the package step. - CSC_IDENTITY_AUTO_DISCOVERY: "false" + - name: Build, package and launch the desktop app + run: pnpm test:desktop + - uses: actions/upload-artifact@v4 + if: always() + with: + name: packaged-desktop-feedback + path: | + packages/harness/desktop-report/ + packages/harness/desktop-test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 254ea23..e2c0467 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ pnpm-debug.log* packages/desktop/release/ packages/harness/test-results/ packages/harness/playwright-report/ +packages/harness/desktop-test-results/ +packages/harness/desktop-report/ diff --git a/AGENTS.md b/AGENTS.md index 9a49d95..83dd71e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ Close the loop before declaring done. Available tools: - `pnpm lint` — oxlint - `pnpm fmt:check` — oxfmt - `pnpm test:e2e` — browser scenario through the built web app, real API, sync engine, and temporary SQLite cache. Install Chromium once with `pnpm --filter harness exec playwright install chromium`. Failure traces, screenshots, and diagnostics go to `packages/harness/test-results/` and `playwright-report/`. +- `pnpm test:desktop` — macOS packaged-app launch/restart/SQLite/shutdown check; restores the host native addon after packaging. +- `pnpm check:boundaries` — enforce sync-only GitHub access (also part of lint). - `pnpm scenario` — interactive isolated board; enter `queued`, `queue-failed`, `offline`, or `in-flight` to drive upstream transitions. See `packages/harness/README.md`. - `pnpm dev:web` — runs server + web concurrently (long-running; start in background). For agent work prefer this over `pnpm dev`, which also spawns an Electron window. Stdout/stderr is tee'd to `.logs/server.log` and `.logs/web.log` — tail or read these to inspect output. Logs reset on each start; accumulate across hot reloads within a session. diff --git a/package.json b/package.json index b808f3c..01e732a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "dev:cli": "pnpm --filter cli dev", "scenario": "pnpm --filter web build && pnpm --filter harness scenario", "test:e2e": "pnpm --filter sync build && pnpm --filter web build && pnpm --filter harness test:e2e", - "check:boundaries": "node --test scripts/check-boundaries.test.mjs && node scripts/check-boundaries.mjs" + "check:boundaries": "node --test scripts/check-boundaries.test.mjs && node scripts/check-boundaries.mjs", + "test:desktop": "pnpm build && pnpm --filter desktop build && pnpm --filter harness test:desktop" }, "devDependencies": { "@typescript/native-preview": "7.0.0-dev.20260420.1", diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 2867158..369e1d1 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -10,6 +10,11 @@ const DEV_WEB_URL = "http://localhost:7200"; // surfaces as "Electron" in the macOS app menu. Force it here so the dev menu // matches the packaged build (Info.plist sets it there). app.setName("GitHub Dashboard"); +// Keep automated runs (including Chromium session files) out of the real profile. +if (process.env.GHD_USER_DATA_DIR) { + app.setPath("userData", process.env.GHD_USER_DATA_DIR); + app.setPath("sessionData", process.env.GHD_USER_DATA_DIR); +} let mainWindow: BrowserWindow | null = null; let serverPort = 0; @@ -217,7 +222,7 @@ app.whenReady().then(async () => { Menu.setApplicationMenu(buildAppMenu()); await createWindow(); - if (app.isPackaged) { + if (app.isPackaged && process.env.GHD_DISABLE_UPDATES !== "1") { setupAutoUpdater(); } }); diff --git a/packages/harness/README.md b/packages/harness/README.md index bc42523..2218757 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -25,3 +25,9 @@ Additional phases: `conflicts`, `needs-approval`, `changes-requested`, `empty`, ## Visual review Every browser run captures named states (in-flight, queued, queue-failed, outage, recovered, conflicts, approval/review changes, empty lists, partial outage, and rate-limit pause). View PNG attachments in the HTML report, or download the `browser-feedback-loop` Actions artifact, uploaded on successful and failed runs. Screenshots use a pinned viewport, light theme, locale and timezone with animations disabled. These are review artifacts, not pixel-diff assertions; text/structure/state assertions determine correctness. + +## Packaged Electron smoke test + +On macOS, run `pnpm test:desktop`. It builds the app, creates an unsigned directory package without publishing, and launches that actual executable twice. The check verifies `app.isPackaged`, renderer data, the packaged SQLite addon, persistence across restart, isolated profile/config/cache paths, successful exit, and closure of each embedded HTTP server. It disables update downloads for this isolated run. The runner restores and probes the host Node SQLite ABI in `finally` because pnpm shares the addon with Electron packaging. + +Reports and screenshots are in `packages/harness/desktop-report/` and `desktop-test-results/`, also uploaded by Desktop CI. `GHD_PACKAGED_APP` can select an existing executable for direct Playwright runs; the default package path is macOS-specific. This does not test signing, installation, or updater delivery. diff --git a/packages/harness/package.json b/packages/harness/package.json index 2179a6d..ff69035 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -6,7 +6,8 @@ "typecheck": "tsgo --noEmit", "test": "vitest run", "scenario": "tsx --conditions=development src/run.ts", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:desktop": "node scripts/desktop-smoke.mjs" }, "dependencies": { "sync": "workspace:*" diff --git a/packages/harness/playwright.desktop.config.ts b/packages/harness/playwright.desktop.config.ts new file mode 100644 index 0000000..f13e712 --- /dev/null +++ b/packages/harness/playwright.desktop.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./src", + testMatch: "**/*.desktop.ts", + workers: 1, + retries: 0, + timeout: 90_000, + expect: { timeout: 15_000 }, + outputDir: "desktop-test-results", + reporter: [ + ["list"], + ["html", { open: "never", outputFolder: "desktop-report" }], + ], +}); diff --git a/packages/harness/scripts/desktop-smoke.mjs b/packages/harness/scripts/desktop-smoke.mjs new file mode 100644 index 0000000..aaac89f --- /dev/null +++ b/packages/harness/scripts/desktop-smoke.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("../../..", import.meta.url))); +function run(args, env = process.env) { + const result = spawnSync("pnpm", args, { cwd: root, env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error(`pnpm ${args.join(" ")} failed (${result.status})`); +} +try { + run(["--filter", "desktop", "package:dir", "--publish", "never"], { + ...process.env, + CSC_IDENTITY_AUTO_DISCOVERY: "false", + }); + run([ + "--filter", + "harness", + "exec", + "playwright", + "test", + "-c", + "playwright.desktop.config.ts", + ]); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + // pnpm shares the addon: packaging rebuilds it for Electron's ABI. Restore + // the host Node ABI even after failure, so dev servers and tests still work. + try { + run(["--filter", "sync", "rebuild", "better-sqlite3"]); + run([ + "--filter", + "sync", + "exec", + "node", + "-e", + "const D=require('better-sqlite3'); const db=new D(':memory:'); db.close(); console.log('Host SQLite ABI restored');", + ]); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/packages/harness/src/app.desktop.ts b/packages/harness/src/app.desktop.ts new file mode 100644 index 0000000..b2b4f15 --- /dev/null +++ b/packages/harness/src/app.desktop.ts @@ -0,0 +1,153 @@ +import { + _electron, + test, + expect, + type ElectronApplication, +} from "@playwright/test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { startGitHub, SCENARIO_TITLE, SCENARIO_TOKEN } from "./github.js"; +import { captureState } from "./artifacts.js"; + +const repo = resolve(import.meta.dirname, "../../.."); +const executablePath = + process.env.GHD_PACKAGED_APP ?? + join( + repo, + "packages/desktop/release", + process.arch === "arm64" ? "mac-arm64" : "mac", + "GitHub Dashboard.app/Contents/MacOS/GitHub Dashboard", + ); + +test("packaged Electron loads SQLite, persists across restart, and closes its embedded server", async () => { + const testInfo = test.info(); + test.skip( + process.platform !== "darwin" && !process.env.GHD_PACKAGED_APP, + "Default packaged path is macOS; pass GHD_PACKAGED_APP on other platforms", + ); + const root = mkdtempSync(join(tmpdir(), "ghd-packaged-")); + const github = await startGitHub(); + github.setPhase("queued"); + const config = join(root, "config/github-dashboard"); + const userData = join(root, "user-data"); + const cacheFile = join(root, "cache/github-dashboard/cache.sqlite"); + mkdirSync(config, { recursive: true }); + mkdirSync(userData); + writeFileSync( + join(config, "config.yml"), + `instances:\n - domain: ${github.domain}\n label: Packaged scenario\n token: ${SCENARIO_TOKEN}\n`, + { mode: 0o600 }, + ); + const env: Record = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + delete env.ELECTRON_RUN_AS_NODE; + Object.assign(env, { + XDG_CONFIG_HOME: join(root, "config"), + XDG_CACHE_HOME: join(root, "cache"), + GHD_USER_DATA_DIR: userData, + GHD_DISABLE_UPDATES: "1", + DEMO: "0", + }); + let application: ElectronApplication | undefined; + const logs: string[] = []; + const errors: string[] = []; + const origins: string[] = []; + try { + for (const launch of ["first-launch", "restart"] as const) { + application = await _electron.launch({ executablePath, env }); + application + .process() + .stdout?.on("data", (chunk) => logs.push(chunk.toString())); + application + .process() + .stderr?.on("data", (chunk) => logs.push(chunk.toString())); + const details = await application.evaluate(({ app }) => ({ + packaged: app.isPackaged, + path: app.getAppPath(), + userData: app.getPath("userData"), + })); + expect(details.packaged).toBe(true); + expect(details.path).toMatch(/app\.asar$/); + expect(details.userData).toBe(userData); + if (launch === "restart") { + // Read before depending on a new response: the previous process wrote + // this snapshot, using the native addon shipped inside the package. + expect(await persisted(application, cacheFile)).toMatchObject([ + { number: 101, inMergeQueue: true }, + ]); + } + const page = await application.firstWindow(); + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + await expect( + page.getByRole("listitem").filter({ hasText: SCENARIO_TITLE }), + ).toHaveCount(1); + await expect( + page.getByRole("img", { name: "In merge queue", exact: true }), + ).toBeVisible(); + await expect( + page.getByLabel("Sync status: Up to date", { exact: true }), + ).toBeVisible(); + expect(await persisted(application, cacheFile)).toMatchObject([ + { number: 101, inMergeQueue: true }, + ]); + await captureState(page, testInfo, launch); + const origin = new URL(page.url()).origin; + origins.push(origin); + expect(new URL(origin).hostname).toBe("localhost"); + const process = application.process(); + await application.close(); + expect(process.exitCode).toBe(0); + application = undefined; + await expect + .poll(async () => { + try { + await fetch(`${origin}/api/config`, { + signal: AbortSignal.timeout(500), + }); + return false; + } catch { + return true; + } + }) + .toBe(true); + } + expect(errors).toEqual([]); + } finally { + if (application) await application.close(); + await testInfo.attach("packaged-diagnostics", { + body: JSON.stringify( + { logs, errors, origins, requests: github.requests }, + null, + 2, + ), + contentType: "application/json", + }); + await github.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +async function persisted(application: ElectronApplication, cacheFile: string) { + return application.evaluate(async ({ app }, file) => { + const { createRequire } = process.getBuiltinModule("module"); + const packageRequire = createRequire(`${app.getAppPath()}/package.json`); + const Database = packageRequire("better-sqlite3"); + const db = new Database(file, { readonly: true }); + try { + return ( + db.prepare("SELECT payload FROM prs WHERE kind='prs'").all() as { + payload: string; + }[] + ).map((row) => JSON.parse(row.payload)); + } finally { + db.close(); + } + }, cacheFile); +}