Skip to content

Commit be34a95

Browse files
committed
test(chain): wait on states with short timings and 100 ms polling
The chain starts its hub with every Triage wait short and polls every 100 ms. No scenario sleeps for a fixed time or asserts a duration: woken waits for its manager to poll again, unset reads the hub's answer to the delivery, unrouted keeps the reconcile pass out of reach instead of timing the retry, and local-sidecars asserts each cause in its own test. A lint rule under tests/chain fails on a literal real sleep of a second or more, and AGENTS.md says a failed chain shard is read, never rerun.
1 parent fc27c38 commit be34a95

14 files changed

Lines changed: 152 additions & 67 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Check these for a stock route or primitive before touching the hub, sidecars or
5757
- Tests beside the code are fast and offline; anything that runs real services lives in `e2e/`.
5858
- The one exception is `tests/chain/`: it starts the real hub and local sidecars but reaches no outside service (a fake GitHub and a stub decision model), and CI runs it as its own job, `bun run test:chain`.
5959
- Never read `process.env` in tests.
60+
- A failed chain shard is read, never rerun.
6061
- A change to how a workflow judges or reviews states its package's eval result before and after.
6162

6263
## Docs

‎docs/DEV.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ Hub tests connect to `postgres://postgres:postgres@localhost:5432/interchange` a
8989

9090
`bun run test:chain` (`tests/chain/`) runs its files two at a time, one scenario each, each isolated from the others. Each file starts a hub on a database of its own, serving a portal build, sets up a workspace through the portal's routes, waits for the hub to start its listener, and sends GitHub's webhooks to it. GitHub and the decision model are local stand-ins, so it needs no account or key; each hub's sidecars are local processes, and it installs the workflows' native modules from the npm registry as a deploy does. Code review needs an isolated sidecar, which a local process is not, so it is not part of the chain test.
9191

92+
The chain starts its hub with every Triage wait short (`TIMINGS` in `tests/chain/stack.ts`, set through the `TRIAGE_*_MS` variables in [SELF_HOST.md](SELF_HOST.md#triage-tuning)); the hub publishes the listener's and manager's share in their release, since a workflow's process gets none of the hub's environment. A scenario polls every 100 ms for the state it names and never sleeps for a fixed time, which `bun run lint` enforces under `tests/chain/`. The one real wait left is stock's one-minute ping timeout in the scenarios that freeze a listener's sidecar. A failed chain shard is read, never rerun.
93+
9294
In CI the chain runs in five shards, each with its own list of files in `.github/workflows/ci.yml`; a new chain file must be added to one. A pull request that changes none of `apps/hub`, `apps/sidecar`, `apps/web/scripts`, `apps/web/src/lib`, `tooling/`, `packages/`, `tests/chain/`, `vendor/`, the lockfile, the root `package.json` or the CI workflow and setup actions skips the chain, so a change limited to the portal's pages and components, docs, e2e or research does not run it; every push to `main` runs it.
9395

9496
Scripts are listed in [IMPLEMENTATION.md](IMPLEMENTATION.md#scripts).

‎tests/chain/.oxlintrc.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"jsPlugins": ["./lint.mjs"],
3+
"rules": { "chain/no-real-sleep": "error" }
4+
}

‎tests/chain/chain.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { parseTriageState, triageStateName, type PrTriageRow } from "@corbits/tr
1212
import { saveAutomationMode } from "../../apps/web/src/lib/hub-api.ts";
1313
import { fakeGithub, OWNER, type Pull, type Write } from "./github.ts";
1414
import { stubModel, type Answer, type Question } from "./model.ts";
15-
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
16-
15+
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, POLL_MS, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
1716

1817
const MANAGER = "triage-manager-github";
19-
// Every deployment starts a sidecar process of its own, and a judged pull request starts two in turn.
18+
// The deadline on a manager settling: every deployment starts a sidecar process of its own, and a judged pull request
19+
// starts two in turn.
2020
export const SETTLE_MS = 150_000;
2121

2222
export const DETERMINISTIC = `${OWNER}/deterministic`;
@@ -77,6 +77,7 @@ export function chainStack(env: Record<string, string> = {}) {
7777
const model = stubModel(decide);
7878
const portalDir = mkdtempSync(join(tmpdir(), "corbits-chain-portal-"));
7979
let database: Database;
80+
let handle: ReturnType<typeof createDB>;
8081
let hub: Hub;
8182
let workspace: Workspace;
8283

@@ -95,6 +96,7 @@ export function chainStack(env: Record<string, string> = {}) {
9596
github.addRepo(UNROUTED, policy([MAX_FILES]));
9697
const [created] = await Promise.all([createDatabase(), buildPortal(portalDir)]);
9798
database = created;
99+
handle = createDB(database.config);
98100
hub = await startHub(database, github, portalDir, env);
99101
workspace = await signUp(hub, database);
100102
// Until a scenario switches it to policy, only posting runs on its own, so a Do in a policy is held.
@@ -105,16 +107,17 @@ export function chainStack(env: Record<string, string> = {}) {
105107

106108
afterAll(async () => {
107109
await hub?.stop();
110+
await handle?.close();
108111
if (database) await dropDatabase(database);
109112
github.stop();
110113
model.stop();
111114
rmSync(portalDir, { recursive: true, force: true });
112115
});
113116

114-
/** GitHub's webhook for a pull request opened or pushed to, after the fake shows that head. */
115-
async function pushed(repo: string, pull: Pull, action: "opened" | "synchronize" = "opened"): Promise<void> {
117+
/** GitHub's webhook for a pull request opened or pushed to, after the fake shows that head; the hub's answer to it. */
118+
async function pushed(repo: string, pull: Pull, action: "opened" | "synchronize" = "opened"): Promise<string> {
116119
github.open(repo, pull);
117-
await deliver(hub, workspace, "pull_request", {
120+
return deliver(hub, workspace, "pull_request", {
118121
action, installation: { id: 1 }, repository: { full_name: repo },
119122
pull_request: { number: pull.number, state: "open", draft: false, head: { sha: pull.head }, user: { login: pull.author }, title: pull.title, body: pull.body },
120123
});
@@ -159,18 +162,13 @@ export function chainStack(env: Record<string, string> = {}) {
159162
while (Date.now() < deadline) {
160163
const value = await read();
161164
if (value !== undefined) return value;
162-
await Bun.sleep(500);
165+
await Bun.sleep(POLL_MS);
163166
}
164167
throw new Error(`Timed out waiting for ${what}.\nRuns:\n${await runEvents()}\nGitHub misses: ${github.misses.join(", ")}\nHub:\n${hub.logs.slice(-80).join("\n")}`);
165168
}
166169

167170
async function rows(query: string): Promise<Record<string, unknown>[]> {
168-
const { db, close } = createDB(database.config);
169-
try {
170-
return [...await db.execute(sql.raw(query))] as Record<string, unknown>[];
171-
} finally {
172-
await close();
173-
}
171+
return [...await handle.db.execute(sql.raw(query))] as Record<string, unknown>[];
174172
}
175173

176174
async function claimOf(repo: string, number: number): Promise<Claim | undefined> {

‎tests/chain/disconnected.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
// A listener whose sidecar stops answering.
22
import { expect, test } from "bun:test";
33
import { TRIAGE_LISTENERS, VERDICT_LABELS } from "@corbits/triage-contracts";
4-
import { RECONCILE_INTERVAL_MS } from "./stack.ts";
4+
import { TIMINGS } from "./stack.ts";
55
import { chainStack, SETTLE_MS, DISCONNECTED, isLiveDeployment, sha, pullOf, comments, labelled, once } from "./chain.ts";
66

77
const chain = chainStack();
88
const { github, pushed, until, rows, settled } = chain;
99

1010
// The hub drops a silent sidecar's socket after stock's one-minute ping timeout, and the next reconcile pass checks it.
11-
const DISCONNECT_MS = 60_000 + 2 * RECONCILE_INTERVAL_MS + SETTLE_MS;
11+
// That timeout is the one real wait left: a sidecar pings on stock's own 30-second interval, which nothing here shortens.
12+
const DISCONNECT_MS = 60_000 + 2 * TIMINGS.reconcileIntervalMs + SETTLE_MS;
1213

1314
/** Every GitHub listener deployment's anchor run, oldest first, and the pid of the local sidecar it runs on. */
1415
async function listeners(): Promise<{ id: string; status: string; pid: number }[]> {

‎tests/chain/lint.mjs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// The chain's lint rule: a scenario waits on the state it names, never on a fixed real duration, so a sleep or timer of
2+
// a second or more written as a literal fails `bun run lint`.
3+
const LIMIT_MS = 1_000;
4+
5+
function delayOf(node) {
6+
const { callee } = node;
7+
if (callee.type === "MemberExpression" && callee.object.name === "Bun" && callee.property.name === "sleep") return node.arguments[0];
8+
if (callee.type === "Identifier" && callee.name === "sleep") return node.arguments[0];
9+
if (callee.type === "Identifier" && callee.name === "setTimeout") return node.arguments[1];
10+
return undefined;
11+
}
12+
13+
export default {
14+
meta: { name: "chain" },
15+
rules: {
16+
"no-real-sleep": {
17+
create(context) {
18+
return {
19+
CallExpression(node) {
20+
const delay = delayOf(node);
21+
if (delay?.type === "Literal" && typeof delay.value === "number" && delay.value >= LIMIT_MS) {
22+
context.report({ node, message: `Wait on the state the scenario names, not ${delay.value} ms of real time.` });
23+
}
24+
},
25+
};
26+
},
27+
},
28+
},
29+
};

‎tests/chain/local-sidecars.test.ts‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { sql } from "drizzle-orm";
88
import { createDB } from "@intx/db";
99
import { fakeGithub, OWNER, type Pull } from "./github.ts";
1010
import { stubModel } from "./model.ts";
11-
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
11+
import { once } from "./chain.ts";
12+
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, POLL_MS, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
1213

1314
setDefaultTimeout(10 * 60_000);
1415

@@ -79,7 +80,7 @@ async function until<T>(what: string, read: () => Promise<T | undefined>, ms = 1
7980
while (Date.now() < deadline) {
8081
const value = await read();
8182
if (value !== undefined) return value;
82-
await Bun.sleep(500);
83+
await Bun.sleep(POLL_MS);
8384
}
8485
throw new Error(`Timed out waiting for ${what}.\n${hub.logs.slice(-80).join("\n")}`);
8586
}
@@ -95,7 +96,7 @@ async function claimOf(repo: string): Promise<Claim | undefined> {
9596
return row?.["state"] as Claim | undefined;
9697
}
9798

98-
test("the listener and two live managers each run in a sidecar of their own, and a released manager's sidecar is gone", async () => {
99+
const managed = once(async function managed() {
99100
const releaseFirst = github.hold(FIRST, 1);
100101
const releaseSecond = github.hold(SECOND, 1);
101102
await pushed(FIRST, sha("first-1"));
@@ -108,12 +109,8 @@ test("the listener and two live managers each run in a sidecar of their own, and
108109
const claim = await claimOf(SECOND);
109110
return claim?.status === "live" ? claim : undefined;
110111
});
111-
expect(first.runId).not.toBe(second.runId);
112-
113112
const live = await rows(`select a.id, a.sidecar_id, r.status, coalesce(s.frozen_approval_bundle -> 'projection' ->> 'id', d.name) as workflow from sidecar_allocation a join workflow_run r on r.id = a.anchor_run_id join workflow_definition d on d.id = r.definition_id left join workflow_run_launch_spec s on s.anchor_run_id = r.id where a.status = 'allocated'`);
114-
expect(live.filter((row) => row["status"] === "running").map((row) => row["workflow"]).sort()).toEqual(["triage-listener-github", "triage-manager-github", "triage-manager-github"]);
115-
expect(new Set(live.map((row) => row["sidecar_id"])).size).toBe(live.length);
116-
expect(manifests().sort()).toEqual(live.map((row) => `${String(row["id"])}.json`).sort());
113+
const liveManifests = manifests();
117114

118115
await pushed(FIRST, sha("first-2"), "synchronize");
119116
await until("the first push's second head to be recorded", async () => {
@@ -130,21 +127,38 @@ test("the listener and two live managers each run in a sidecar of their own, and
130127
const claim = await claimOf(SECOND);
131128
return claim !== undefined && claim.history.length >= 1 ? claim : undefined;
132129
});
133-
expect(firstDone.history.map((pass) => pass.runId)).toEqual([first.runId!, first.runId!]);
134-
expect(firstDone.history.at(-1)!.head).toBe(sha("first-2"));
135-
expect(secondDone.history).toEqual([expect.objectContaining({ head: sha("second-1"), runId: second.runId })]);
136-
expect(github.pull(FIRST, 1).comments.map((comment) => comment.body)).toContainEqual(expect.stringContaining("**Triage: Pass**"));
137-
expect(github.pull(SECOND, 1).comments.map((comment) => comment.body)).toEqual([expect.stringContaining("**Triage: Pass**")]);
138130
const managerAllocations = live.filter((row) => row["workflow"] === "triage-manager-github").map((row) => `'${String(row["id"])}'`).join(",");
139131
const released = await until("both managers' allocations to be released", async () => {
140132
const found = await rows(`select status, deployment_cleanup_confirmed from sidecar_allocation where id in (${managerAllocations})`);
141133
return found.every((row) => row["status"] === "released") ? found : undefined;
142134
});
143-
expect(released).toEqual([{ status: "released", deployment_cleanup_confirmed: true }, { status: "released", deployment_cleanup_confirmed: true }]);
144-
const listenerRow = live.find((row) => row["workflow"] === "triage-listener-github")!;
145-
expect(manifests()).toEqual([`${String(listenerRow["id"])}.json`]);
135+
const listenerId = String(live.find((row) => row["workflow"] === "triage-listener-github")!["id"]);
136+
const listener = await rows(`select a.status, r.status as run from sidecar_allocation a join workflow_run r on r.id = a.anchor_run_id where a.id = '${listenerId}'`);
146137
const dataDirs = readdirSync(join(hub.dataDir, "local-sidecars")).filter((name) => name !== "manifests");
147-
expect(dataDirs).toEqual([expect.stringMatching(new RegExp(`^${String(listenerRow["id"])}-`))]);
148-
const listener = await rows(`select a.status, r.status as run from sidecar_allocation a join workflow_run r on r.id = a.anchor_run_id where a.id = '${String(listenerRow["id"])}'`);
138+
return { first, second, live, liveManifests, firstDone, secondDone, released, listenerId, listener, manifests: manifests(), dataDirs };
139+
});
140+
141+
test("the listener and two live managers each run in a sidecar of their own", async () => {
142+
const { first, second, live, liveManifests } = await managed();
143+
expect(first.runId).not.toBe(second.runId);
144+
expect(live.filter((row) => row["status"] === "running").map((row) => row["workflow"]).sort()).toEqual(["triage-listener-github", "triage-manager-github", "triage-manager-github"]);
145+
expect(new Set(live.map((row) => row["sidecar_id"])).size).toBe(live.length);
146+
expect(liveManifests.sort()).toEqual(live.map((row) => `${String(row["id"])}.json`).sort());
147+
});
148+
149+
test("each manager receives only its own pull request's mail", async () => {
150+
const { first, second, firstDone, secondDone } = await managed();
151+
expect(firstDone.history.map((pass) => pass.runId)).toEqual([first.runId!, first.runId!]);
152+
expect(firstDone.history.at(-1)!.head).toBe(sha("first-2"));
153+
expect(secondDone.history).toEqual([expect.objectContaining({ head: sha("second-1"), runId: second.runId })]);
154+
expect(github.pull(FIRST, 1).comments.map((comment) => comment.body)).toContainEqual(expect.stringContaining("**Triage: Pass**"));
155+
expect(github.pull(SECOND, 1).comments.map((comment) => comment.body)).toEqual([expect.stringContaining("**Triage: Pass**")]);
156+
});
157+
158+
test("a released manager's sidecar is stopped and removed, and the listener's runs on", async () => {
159+
const { released, listenerId, listener, manifests: left, dataDirs } = await managed();
160+
expect(released).toEqual([{ status: "released", deployment_cleanup_confirmed: true }, { status: "released", deployment_cleanup_confirmed: true }]);
161+
expect(left).toEqual([`${listenerId}.json`]);
162+
expect(dataDirs).toEqual([expect.stringMatching(new RegExp(`^${listenerId}-`))]);
149163
expect(listener).toEqual([{ status: "allocated", run: "running" }]);
150164
});

‎tests/chain/reread.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// A head whose CI finishes with no event: its manager ended while it waited, and the hub's reconcile pass wakes the next.
22
import { expect, test } from "bun:test";
33
import { VERDICT_LABELS } from "@corbits/triage-contracts";
4-
import { RECONCILE_INTERVAL_MS } from "./stack.ts";
4+
import { TIMINGS } from "./stack.ts";
55
import { chainStack, REREAD, SETTLE_MS, isLiveDeployment, sha, pullOf, once } from "./chain.ts";
66

77
const { github, pushed, until, managers, settled, verdictsOf } = chainStack();
@@ -16,7 +16,7 @@ const reread = once(async function reread() {
1616
const whileWaiting = { comments: github.pull(REREAD, 1).comments.map((comment) => comment.body), claim: ended, managers: (await managers()).slice(before) };
1717
// CI finishes with no check_run, as on a host whose builds send no webhook.
1818
github.setChecks(REREAD, head, [{ name: "build", status: "completed", conclusion: "success" }]);
19-
const claim = await settled(REREAD, 1, ended.history.length + 1, 2 * RECONCILE_INTERVAL_MS + SETTLE_MS);
19+
const claim = await settled(REREAD, 1, ended.history.length + 1, 2 * TIMINGS.reconcileIntervalMs + SETTLE_MS);
2020
return { head, whileWaiting, claim, deployed: (await managers()).slice(before), pull: github.pull(REREAD, 1) };
2121
});
2222

‎tests/chain/sidecar-cap.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { tmpdir } from "node:os";
66
import { join } from "node:path";
77
import { sql } from "drizzle-orm";
88
import { createDB } from "@intx/db";
9+
import { DEFAULT_TIMINGS, TIMING_VARIABLES } from "@corbits/triage-contracts";
910
import { fakeGithub, OWNER, type Pull } from "./github.ts";
1011
import { stubModel } from "./model.ts";
11-
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
12+
import { buildPortal, createDatabase, deliver, dropDatabase, listenerStarted, POLL_MS, setUpWorkspace, signUp, startHub, type Database, type Hub, type Workspace } from "./stack.ts";
1213

1314
setDefaultTimeout(10 * 60_000);
1415

@@ -30,7 +31,8 @@ beforeAll(async () => {
3031
github.addRepo(SECOND, POLICY);
3132
const [created] = await Promise.all([createDatabase(), buildPortal(portalDir)]);
3233
database = created;
33-
hub = await startHub(database, github, portalDir, { TRIAGE_MAX_LOCAL_SIDECARS: "2" });
34+
// The second manager's wake waits out the first manager's whole pass for a slot, so it keeps the production window.
35+
hub = await startHub(database, github, portalDir, { TRIAGE_MAX_LOCAL_SIDECARS: "2", [TIMING_VARIABLES.managerReadyMs]: String(DEFAULT_TIMINGS.managerReadyMs) });
3436
workspace = await signUp(hub, database);
3537
await setUpWorkspace(workspace, github, model.origin);
3638
await listenerStarted(workspace);
@@ -75,7 +77,7 @@ async function until<T>(what: string, read: () => Promise<T | undefined>, ms = 1
7577
while (Date.now() < deadline) {
7678
const value = await read();
7779
if (value !== undefined) return value;
78-
await Bun.sleep(500);
80+
await Bun.sleep(POLL_MS);
7981
}
8082
throw new Error(`Timed out waiting for ${what}.\n${hub.logs.slice(-80).join("\n")}`);
8183
}

0 commit comments

Comments
 (0)