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
17 changes: 17 additions & 0 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
74 changes: 74 additions & 0 deletions packages/harness/src/github.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
159 changes: 159 additions & 0 deletions packages/harness/src/github.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeIdleConnections();
}),
};
}

function pullRequest(phase: Exclude<Phase, "offline">) {
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 },
};
}
13 changes: 13 additions & 0 deletions packages/harness/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading