Skip to content
Draft
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
19 changes: 10 additions & 9 deletions .github/workflows/desktop-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
});
Expand Down
6 changes: 6 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
15 changes: 15 additions & 0 deletions packages/harness/playwright.desktop.config.ts
Original file line number Diff line number Diff line change
@@ -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" }],
],
});
46 changes: 46 additions & 0 deletions packages/harness/scripts/desktop-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
153 changes: 153 additions & 0 deletions packages/harness/src/app.desktop.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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);
}
Loading