diff --git a/package.json b/package.json index 61de316..eab134b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test": "pnpm --filter sync build && pnpm --filter cli build && pnpm -r test", "prepare": "husky", "cli": "node packages/cli/dist/index.js", - "dev:cli": "pnpm --filter cli dev" + "dev:cli": "pnpm --filter cli dev", + "scenario": "pnpm --filter web build && pnpm --filter harness scenario" }, "devDependencies": { "@typescript/native-preview": "7.0.0-dev.20260420.1", diff --git a/packages/harness/README.md b/packages/harness/README.md new file mode 100644 index 0000000..920fbde --- /dev/null +++ b/packages/harness/README.md @@ -0,0 +1,9 @@ +# Scenario harness + +Run `pnpm scenario` from the repo root. It builds the renderer and prints a local board URL. This is the real Hono API and sync engine, backed by a temporary SQLite database and a loopback GitHub simulator. It does not use the demo-data bypass or read your GitHub credentials. + +Enter `queued`, then `queue-failed`, to move the fixture PR into and out of the merge queue. `offline` makes GitHub return 503; `in-flight` restores it. Each command requests a fresh sync through the API. The board polls every 10 seconds; reload it for an immediate update. Use `quit` or Ctrl-C to stop and remove temporary files. + +The current board changes its queue icon; the dedicated merge-queue section in #84 is a subsequent product change. This harness supplies the repeatable state transitions for developing it. + +`pnpm --filter harness test` checks the simulator through the real engine: transitions, outage, recovery, and persisted snapshots across restart. Unsupported GitHub calls fail with 501 so new engine behavior requires an explicit fixture update. diff --git a/packages/harness/package.json b/packages/harness/package.json index 4a4407c..86fb4ff 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "typecheck": "tsgo --noEmit", - "test": "vitest run" + "test": "vitest run", + "scenario": "tsx --conditions=development src/run.ts" }, "dependencies": { "sync": "workspace:*" diff --git a/packages/harness/src/run.ts b/packages/harness/src/run.ts new file mode 100644 index 0000000..57a4a35 --- /dev/null +++ b/packages/harness/src/run.ts @@ -0,0 +1,56 @@ +import { createInterface } from "node:readline/promises"; +import { startScenario } from "./runtime.js"; +import type { Phase } from "./github.js"; + +const scenario = await startScenario(); +const terminal = createInterface({ + input: process.stdin, + output: process.stdout, +}); +const stop = async () => { + terminal.close(); + await scenario.close(); +}; +process.once("SIGINT", () => void stop()); +process.once("SIGTERM", () => void stop()); +console.log(`Scenario board: ${scenario.url}`); +console.log("Commands: in-flight, queued, queue-failed, offline, sync, quit"); +console.log( + "Each phase command syncs the real API. The board polls every 10s; reload for an immediate update.", +); +try { + for await (const line of terminal) { + const command = line.trim(); + if (command === "quit") break; + if (["in-flight", "queued", "queue-failed", "offline"].includes(command)) { + scenario.setPhase(command as Phase); + } else if (command !== "sync") { + console.log( + "Unknown command. Use in-flight, queued, queue-failed, offline, sync, or quit.", + ); + continue; + } + try { + const prs = await scenario.syncPrs(); + console.log( + JSON.stringify( + prs.map( + (pr: { + number: number; + inMergeQueue: boolean; + ciStatus: string; + }) => ({ + number: pr.number, + queued: pr.inMergeQueue, + ci: pr.ciStatus, + }), + ), + ), + ); + } catch (error) { + console.log(String(error)); + } + } +} finally { + await stop(); +} diff --git a/packages/harness/src/runtime.ts b/packages/harness/src/runtime.ts new file mode 100644 index 0000000..b9f4318 --- /dev/null +++ b/packages/harness/src/runtime.ts @@ -0,0 +1,146 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { createSync } from "sync"; +import { startGitHub, SCENARIO_TOKEN, type Phase } from "./github.js"; + +const repo = resolve(import.meta.dirname, "../../.."); + +/** Runs the real API + built renderer against an isolated upstream and cache. */ +export async function startScenario() { + const root = mkdtempSync(join(tmpdir(), "ghd-scenario-")); + let github: Awaited> | undefined; + let child: ChildProcess | undefined; + let output = ""; + let closing: Promise | undefined; + const close = () => + (closing ??= (async () => { + if (child?.pid && child.exitCode === null && child.signalCode === null) { + const exited = new Promise((resolve) => + child!.once("exit", () => resolve()), + ); + child.kill("SIGTERM"); + const force = setTimeout(() => child?.kill("SIGKILL"), 3000); + force.unref(); + await exited; + clearTimeout(force); + } + await github?.close(); + rmSync(root, { recursive: true, force: true }); + })()); + try { + github = await startGitHub(); + const instanceId = github.instance.id; + const configHome = join(root, "config"); + const cacheHome = join(root, "cache"); + mkdirSync(join(configHome, "github-dashboard"), { recursive: true }); + writeFileSync( + join(configHome, "github-dashboard/config.yml"), + `instances:\n - domain: ${github.domain}\n label: Scenario GitHub\n token: ${SCENARIO_TOKEN}\n`, + { mode: 0o600 }, + ); + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + child = spawn( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + "--conditions=development", + join(repo, "packages/server/src/index.ts"), + ], + { + cwd: repo, + env: { + ...process.env, + PORT: String(port), + DEMO: "0", + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: cacheHome, + GHD_DATA_DIR: root, + GHD_WEB_DIST: join(repo, "packages/web/dist"), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout?.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + output += chunk.toString(); + }); + let spawnError: Error | undefined; + child.on("error", (error) => { + spawnError = error; + }); + const deadline = Date.now() + 10_000; + while (true) { + if (spawnError) throw spawnError; + if (child.exitCode !== null) + throw new Error(`Scenario server exited:\n${output}`); + try { + const response = await fetch(`${url}/api/config`, { + signal: AbortSignal.timeout(1000), + }); + if (response.ok && (await response.json()).status.kind === "ready") + break; + } catch { + /* Server is still starting. */ + } + if (Date.now() > deadline) + throw new Error(`Scenario server did not become ready:\n${output}`); + await delay(50); + } + return { + url, + instanceId, + setPhase(phase: Phase) { + github!.setPhase(phase); + }, + async syncPrs() { + const response = await fetch(`${url}/api/${instanceId}/prs?fresh=1`); + if (!response.ok) + throw new Error(`Scenario sync failed (${response.status})`); + return response.json(); + }, + async persistedPrs() { + // Open the actual database through the public engine API. Only its + // synchronous constructor needs the scoped environment override. + const previous = process.env.XDG_CACHE_HOME; + let reader: ReturnType; + try { + process.env.XDG_CACHE_HOME = cacheHome; + reader = createSync(); + } finally { + if (previous === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = previous; + } + try { + return reader.listPullRequests(instanceId, "prs"); + } finally { + await reader.close(); + } + }, + diagnostics: () => ({ server: output, requests: [...github!.requests] }), + close, + }; + } catch (error) { + await close(); + throw error; + } +} + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (!address || typeof address === "string") throw new Error("Missing port"); + return address.port; +}