diff --git a/packages/harness/package.json b/packages/harness/package.json new file mode 100644 index 0000000..4a4407c --- /dev/null +++ b/packages/harness/package.json @@ -0,0 +1,17 @@ +{ + "name": "harness", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "sync": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.19.3", + "vitest": "^4.1.2" + } +} diff --git a/packages/harness/src/github.test.ts b/packages/harness/src/github.test.ts new file mode 100644 index 0000000..4300e53 --- /dev/null +++ b/packages/harness/src/github.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { createSync } from "sync"; +import { startGitHub } from "./github.js"; + +afterEach(() => vi.unstubAllEnvs()); + +it("syncs queue transitions into SQLite, preserves the last snapshot on failure, and survives restart", async () => { + const root = mkdtempSync(join(tmpdir(), "ghd-simulator-")); + vi.stubEnv("XDG_CACHE_HOME", root); + const github = await startGitHub(); + const options = { loadInstances: () => [github.instance] }; + let engine = createSync(options); + try { + for (const [phase, inMergeQueue, ciStatus] of [ + ["in-flight", false, "success"], + ["queued", true, "pending"], + ["queue-failed", false, "failure"], + ] as const) { + github.setPhase(phase); + const result = await engine.sync(); + expect(result.results[0].fetches.map((f) => f.status)).toEqual([ + "updated", + "updated", + "updated", + ]); + const prs = engine.listPullRequests(github.instance.id, "prs"); + expect(prs).toHaveLength(1); + expect(prs[0]).toMatchObject({ number: 101, inMergeQueue, ciStatus }); + } + github.setPhase("offline"); + const failed = await engine.sync(); + expect(failed.results[0].fetches.every((f) => f.status === "failed")).toBe( + true, + ); + expect(engine.listPullRequests(github.instance.id, "prs")[0].ciStatus).toBe( + "failure", + ); + await engine.close(); + engine = createSync(options); + expect(engine.listPullRequests(github.instance.id, "prs")).toHaveLength(1); + expect(engine.listPullRequests(github.instance.id, "prs")[0]).toMatchObject( + { inMergeQueue: false, ciStatus: "failure" }, + ); + github.setPhase("in-flight"); + await engine.sync(); + expect(engine.listPullRequests(github.instance.id, "prs")[0].ciStatus).toBe( + "success", + ); + expect(github.requests.some((r) => r.path === "/api/graphql")).toBe(true); + } finally { + await engine.close(); + await github.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects unexpected calls instead of pretending they succeeded", async () => { + const github = await startGitHub(); + try { + expect((await fetch(`${github.domain}/api/v3/user`)).status).toBe(401); + expect( + ( + await fetch(`${github.domain}/api/v3/unknown`, { + headers: { authorization: `token ${github.instance.token}` }, + }) + ).status, + ).toBe(501); + } finally { + await github.close(); + } +}); diff --git a/packages/harness/src/github.ts b/packages/harness/src/github.ts new file mode 100644 index 0000000..d23b35f --- /dev/null +++ b/packages/harness/src/github.ts @@ -0,0 +1,159 @@ +import { createServer } from "node:http"; + +export type Phase = "in-flight" | "queued" | "queue-failed" | "offline"; +export const SCENARIO_TITLE = "Ship the status board"; +export const SCENARIO_TOKEN = "scenario-token"; +export const SCENARIO_USER = "scenario-user"; + +/** A deliberately small GitHub boundary; unsupported requests fail loudly. */ +export async function startGitHub() { + let phase: Phase = "in-flight"; + const requests: { method: string; path: string; phase: Phase }[] = []; + const server = createServer(async (req, res) => { + const path = req.url ?? "/"; + requests.push({ method: req.method ?? "GET", path, phase }); + res.setHeader("content-type", "application/json"); + const send = (status: number, body: unknown) => { + res.writeHead(status).end(JSON.stringify(body)); + }; + if ( + req.headers.authorization !== `token ${SCENARIO_TOKEN}` && + req.headers.authorization !== `bearer ${SCENARIO_TOKEN}` + ) { + send(401, { message: "Only scenario credentials are accepted" }); + return; + } + if (phase === "offline") { + send(503, { message: "Scenario: GitHub unavailable" }); + return; + } + if (req.method === "GET" && path === "/api/v3/user") { + send(200, { login: SCENARIO_USER }); + return; + } + if (req.method === "GET" && path.startsWith("/api/v3/notifications?")) { + send(200, []); + return; + } + if (req.method === "POST" && path === "/api/graphql") { + let raw = ""; + for await (const chunk of req) raw += chunk; + try { + const { query, variables } = JSON.parse(raw); + if ( + typeof query !== "string" || + query.includes("mutation") || + !query.includes("fragment PrFields") || + variables.first !== 100 || + ![ + `author:${SCENARIO_USER} type:pr state:open`, + `review-requested:${SCENARIO_USER} type:pr state:open`, + ].includes(variables.q) + ) { + send(501, { message: "Unsupported scenario GraphQL request" }); + return; + } + send(200, { + data: { + search: { + nodes: variables.q.startsWith("author:") + ? [pullRequest(phase)] + : [], + }, + rateLimit: { + cost: 1, + remaining: 5000, + resetAt: "2099-01-01T00:00:00Z", + }, + }, + }); + } catch { + send(400, { message: "Invalid scenario GraphQL request" }); + } + return; + } + send(501, { + message: `Unsupported scenario request: ${req.method} ${path}`, + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing simulator address"); + const domain = `http://127.0.0.1:${address.port}`; + return { + domain, + instance: { + id: `127-0-0-1-${address.port}`, + label: "Scenario GitHub", + baseUrl: `${domain}/api/v3`, + token: SCENARIO_TOKEN, + }, + requests, + setPhase(next: Phase) { + phase = next; + }, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeIdleConnections(); + }), + }; +} + +function pullRequest(phase: Exclude) { + return { + id: "PR_scenario", + databaseId: 101, + number: 101, + title: SCENARIO_TITLE, + body: "A deterministic PR for testing the status board.", + url: "https://github.com/scenario/status-board/pull/101", + createdAt: "2026-01-01T12:00:00Z", + updatedAt: + phase === "in-flight" + ? "2026-01-01T12:00:00Z" + : phase === "queued" + ? "2026-01-01T12:01:00Z" + : "2026-01-01T12:02:00Z", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + headRefName: "feature/status-board", + baseRefName: "main", + author: { login: SCENARIO_USER, avatarUrl: "" }, + repository: { + nameWithOwner: "scenario/status-board", + autoMergeAllowed: true, + }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: + phase === "queue-failed" + ? "FAILURE" + : phase === "queued" + ? "PENDING" + : "SUCCESS", + }, + }, + }, + ], + }, + reviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + reviewThreads: { totalCount: 0, nodes: [] }, + autoMergeRequest: null, + mergeQueueEntry: phase === "queued" ? { id: "queue-entry" } : null, + labels: { nodes: [] }, + comments: { totalCount: 0 }, + commitsTotal: { totalCount: 2 }, + }; +} diff --git a/packages/harness/tsconfig.json b/packages/harness/tsconfig.json new file mode 100644 index 0000000..d08df5e --- /dev/null +++ b/packages/harness/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb0b6a8..eb7290b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,22 @@ importers: specifier: workspace:* version: link:../server + packages/harness: + dependencies: + sync: + specifier: workspace:* + version: link:../sync + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + tsx: + specifier: ^4.19.3 + version: 4.21.0 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/server: dependencies: '@hono/node-server':