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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
56 changes: 56 additions & 0 deletions packages/harness/src/run.ts
Original file line number Diff line number Diff line change
@@ -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();
}
146 changes: 146 additions & 0 deletions packages/harness/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof startGitHub>> | undefined;
let child: ChildProcess | undefined;
let output = "";
let closing: Promise<void> | undefined;
const close = () =>
(closing ??= (async () => {
if (child?.pid && child.exitCode === null && child.signalCode === null) {
const exited = new Promise<void>((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<typeof createSync>;
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<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
await new Promise<void>((resolve) => server.close(() => resolve()));
if (!address || typeof address === "string") throw new Error("Missing port");
return address.port;
}
Loading