Public: anyone can read this; the crew can change it.
test/fork-pull-approval.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10246
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10238-test-fork-pull-approval-test-ts-runs-in → main 2 commits fork 1 thread · 1 unresolved
1 file
changed +137
−40
test/fork-pull-approval.test.ts +137 −40
| @@ -5,7 +5,15 @@ | ||
| 5 | 5 | * pull and the crew's run as they always have. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { | |
| 9 | afterAll, | |
| 10 | beforeAll, | |
| 11 | describe, | |
| 12 | expect, | |
| 13 | spyOn, | |
| 14 | test, | |
| 15 | type Mock, | |
| 16 | } from "bun:test"; | |
| 9 | 17 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 18 | import { tmpdir } from "node:os"; |
| 11 | 19 | import { join } from "node:path"; |
| @@ -22,10 +30,40 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 22 | 30 | import { listRuns } from "../src/core/runs.ts"; |
| 23 | 31 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts"; | |
| 26 | 34 | import { promote } from "./helpers/members.ts"; |
| 27 | 35 | |
| 28 | quietLogs(); | |
| 36 | /** | |
| 37 | * The forge's info lines, held instead of printed: the gate says "waits for a | |
| 38 | * writer's approval" when it refuses, and that line is the witness that the | |
| 39 | * event was handled. A fixed sleep only guessed it had been. | |
| 40 | */ | |
| 41 | const said: string[] = []; | |
| 42 | let foundLevel: LogLevel; | |
| 43 | const spies: Mock<(...a: unknown[]) => void>[] = []; | |
| 44 | beforeAll(() => { | |
| 45 | foundLevel = logLevel(); | |
| 46 | setLogLevel("info"); | |
| 47 | for (const m of ["info", "warn"] as const) | |
| 48 | spies.push( | |
| 49 | spyOn(console, m).mockImplementation((...a: unknown[]) => { | |
| 50 | said.push(a.join(" ")); | |
| 51 | }), | |
| 52 | ); | |
| 53 | }); | |
| 54 | afterAll(() => { | |
| 55 | for (const spy of spies) spy.mockRestore(); | |
| 56 | setLogLevel(foundLevel); | |
| 57 | }); | |
| 58 | ||
| 59 | /** Poll every 5 ms until `ok`, or fail naming `what`. */ | |
| 60 | async function until(ok: () => boolean, what: string, ms = 10_000) { | |
| 61 | const deadline = Date.now() + ms; | |
| 62 | while (!ok()) { | |
| 63 | if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); | |
| 64 | await Bun.sleep(5); | |
| 65 | } | |
| 66 | } | |
| 29 | 67 | |
| 30 | 68 | const checkJson = (run: string) => |
| 31 | 69 | `{"check": {"run": ${JSON.stringify(run)}, "install": ""}}\n`; |
| @@ -57,8 +95,63 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 57 | 95 | |
| 58 | 96 | const forks = new Map<string, Repo>(); |
| 59 | 97 | |
| 60 | /** Long enough for the event handlers to have queued whatever they would. */ | |
| 61 | const settle = () => Bun.sleep(400); | |
| 98 | /** Whether `line` carries the field `key=value` exactly. */ | |
| 99 | const field = (line: string, key: string, value: string) => | |
| 100 | ` ${line} `.includes(` ${key}=${value} `); | |
| 101 | ||
| 102 | /** | |
| 103 | * How many times the gate refused `env` in one repository — `scope` is | |
| 104 | * `repo=owner/name` for a check, `app=slug` for a preview, since pull | |
| 105 | * numbers repeat across repositories — at `commit`, when named. | |
| 106 | */ | |
| 107 | const refusals = ( | |
| 108 | message: string, | |
| 109 | scope: [string, string], | |
| 110 | env: string, | |
| 111 | commit?: string, | |
| 112 | ) => | |
| 113 | said.filter( | |
| 114 | (line) => | |
| 115 | line.includes(message) && | |
| 116 | field(line, ...scope) && | |
| 117 | field(line, "env", env) && | |
| 118 | (commit === undefined || field(line, "commit", commit.slice(0, 7))), | |
| 119 | ).length; | |
| 120 | ||
| 121 | /** | |
| 122 | * The checks service has handled `pull`'s events up to the `times`th | |
| 123 | * refusal — or, were the gate gone, queued a run the test then sees. | |
| 124 | */ | |
| 125 | const checkDecided = (repo: Repo, pull: Pull, commit?: string, times = 1) => | |
| 126 | until( | |
| 127 | () => | |
| 128 | refusals( | |
| 129 | "check waits for a writer's approval", | |
| 130 | ["repo", `${repo.ownerName}/${repo.name}`], | |
| 131 | `pull-${pull.number}`, | |
| 132 | commit, | |
| 133 | ) >= times || runsFor(repo, "check", pull, commit).length > 0, | |
| 134 | `the check gate on ${repo.name} pull ${pull.number}`, | |
| 135 | ); | |
| 136 | ||
| 137 | /** The same for the apps runtime and `pull`'s preview on the notes app. */ | |
| 138 | const previewDecided = (repo: Repo, pull: Pull) => | |
| 139 | until( | |
| 140 | () => | |
| 141 | refusals( | |
| 142 | "preview waits for a writer's approval", | |
| 143 | ["app", notesApp.slug], | |
| 144 | `pull-${pull.number}`, | |
| 145 | ) > 0 || runsFor(repo, "deploy", pull).length > 0, | |
| 146 | `the preview gate on ${repo.name} pull ${pull.number}`, | |
| 147 | ); | |
| 148 | ||
| 149 | /** Wait for a `kind` run of `pull` at `commit`. */ | |
| 150 | const ran = (repo: Repo, kind: "check" | "deploy", pull: Pull, commit: string) => | |
| 151 | until( | |
| 152 | () => runsFor(repo, kind, pull, commit).length > 0, | |
| 153 | `a ${kind} of pull ${pull.number}`, | |
| 154 | ); | |
| 62 | 155 | |
| 63 | 156 | async function forkPull( |
| 64 | 157 | who: User, |
| @@ -155,7 +248,7 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 155 | 248 | if (!madeApp.ok) throw new Error(String(madeApp.error)); |
| 156 | 249 | notes = madeApp.value.repo; |
| 157 | 250 | notesApp = madeApp.value.app; |
| 158 | }, 120_000); | |
| 251 | }, 20_000); | |
| 159 | 252 | |
| 160 | 253 | afterAll(async () => { |
| 161 | 254 | await checks.stop(); |
| @@ -166,12 +259,14 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 166 | 259 | }); |
| 167 | 260 | |
| 168 | 261 | test("a reader's fork pull queues no check", async () => { |
| 262 | // Invariant: opening a reader's fork pull queues no check run. | |
| 169 | 263 | const { pull } = await forkPull(carol, lib, "readers"); |
| 170 | await settle(); | |
| 264 | await checkDecided(lib, pull); | |
| 171 | 265 | expect(runsFor(lib, "check", pull)).toHaveLength(0); |
| 172 | 266 | }); |
| 173 | 267 | |
| 174 | 268 | test("the pull's page says what it waits for, and stops saying it once approved", async () => { |
| 269 | // Invariant: the page says the pull waits for approval until a writer approves. | |
| 175 | 270 | const { pull } = await forkPull(carol, lib, "page"); |
| 176 | 271 | const page = () => |
| 177 | 272 | fetch(`${servers.url}/alice/lib/pulls/${pull.number}`, { |
| @@ -180,46 +275,47 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 180 | 275 | const first = await page(); |
| 181 | 276 | expect(first).toContain("wait for a writer to approve this head"); |
| 182 | 277 | await approve(alice, lib, pull); |
| 183 | await settle(); | |
| 184 | 278 | expect(await page()).not.toContain("wait for a writer to approve this head"); |
| 185 | 279 | }); |
| 186 | 280 | |
| 187 | 281 | test("a reader's fork pull on an app deploys no preview", async () => { |
| 282 | // Invariant: opening a reader's fork pull on an app deploys no preview. | |
| 188 | 283 | const { pull } = await forkPull(carol, notes, "readers"); |
| 189 | await settle(); | |
| 284 | await previewDecided(notes, pull); | |
| 190 | 285 | expect(runsFor(notes, "deploy", pull)).toHaveLength(0); |
| 191 | 286 | }); |
| 192 | 287 | |
| 193 | 288 | test("a writer's approval of that head starts the check and the preview", async () => { |
| 289 | // Invariant: a writer's approval of the head runs its check and its preview. | |
| 194 | 290 | const one = await forkPull(carol, lib, "approved"); |
| 195 | 291 | const two = await forkPull(carol, notes, "approved"); |
| 196 | await settle(); | |
| 292 | await checkDecided(lib, one.pull); | |
| 293 | await previewDecided(notes, two.pull); | |
| 197 | 294 | expect(runsFor(lib, "check", one.pull)).toHaveLength(0); |
| 295 | expect(runsFor(notes, "deploy", two.pull)).toHaveLength(0); | |
| 198 | 296 | await approve(alice, lib, one.pull); |
| 199 | 297 | await approve(alice, notes, two.pull); |
| 200 | await settle(); | |
| 201 | expect(runsFor(lib, "check", one.pull, one.head).length).toBeGreaterThan(0); | |
| 202 | expect(runsFor(notes, "deploy", two.pull, two.head).length).toBeGreaterThan( | |
| 203 | 0, | |
| 204 | ); | |
| 205 | }, 60_000); | |
| 298 | await ran(lib, "check", one.pull, one.head); | |
| 299 | await ran(notes, "deploy", two.pull, two.head); | |
| 300 | }); | |
| 206 | 301 | |
| 207 | 302 | test("a new push is a new head, and needs a new approval", async () => { |
| 303 | // Invariant: an approval holds for its head only; a pushed head runs nothing. | |
| 208 | 304 | const { pull, fork, head } = await forkPull(carol, lib, "pushes"); |
| 209 | 305 | await approve(alice, lib, pull); |
| 210 | await settle(); | |
| 211 | expect(runsFor(lib, "check", pull, head).length).toBeGreaterThan(0); | |
| 306 | await ran(lib, "check", pull, head); | |
| 212 | 307 | const again = await commitToBranch(app, carol, fork, { |
| 213 | 308 | branch: "pushes", |
| 214 | 309 | message: "Again", |
| 215 | 310 | files: [{ path: "pushes.md", content: "again\n" }], |
| 216 | 311 | }); |
| 217 | 312 | if (!again.ok) throw new Error(JSON.stringify(again.error)); |
| 218 | await settle(); | |
| 313 | await checkDecided(lib, pull, again.value.commitOid); | |
| 219 | 314 | expect(runsFor(lib, "check", pull, again.value.commitOid)).toHaveLength(0); |
| 220 | }, 60_000); | |
| 315 | }); | |
| 221 | 316 | |
| 222 | 317 | test("approval from the crew or from the pull's own reader does not count", async () => { |
| 318 | // Invariant: neither the crew's approval nor the author's own lets it run. | |
| 223 | 319 | const { pull } = await forkPull(carol, lib, "crewvote"); |
| 224 | 320 | const byBot = await submitReview(app, bot, lib, pull, { |
| 225 | 321 | state: "approved", |
| @@ -229,27 +325,24 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 229 | 325 | state: "approved", |
| 230 | 326 | }); |
| 231 | 327 | expect(byCarol.ok).toBe(false); |
| 232 | await settle(); | |
| 328 | // Refused once on opening, once more on the crew's approval. | |
| 329 | await checkDecided(lib, pull, undefined, 2); | |
| 233 | 330 | expect(runsFor(lib, "check", pull)).toHaveLength(0); |
| 234 | 331 | }); |
| 235 | 332 | |
| 236 | 333 | test("a writer's own fork pull and the crew's run as before", async () => { |
| 334 | // Invariant: a writer's and the crew's fork pulls are checked without approval. | |
| 237 | 335 | const mine = await forkPull(alice, lib, "writers"); |
| 238 | 336 | const crews = await forkPull(bot, lib, "crews"); |
| 239 | await settle(); | |
| 240 | expect(runsFor(lib, "check", mine.pull, mine.head).length).toBeGreaterThan( | |
| 241 | 0, | |
| 242 | ); | |
| 243 | expect( | |
| 244 | runsFor(lib, "check", crews.pull, crews.head).length, | |
| 245 | ).toBeGreaterThan(0); | |
| 246 | }, 60_000); | |
| 337 | await ran(lib, "check", mine.pull, mine.head); | |
| 338 | await ran(lib, "check", crews.pull, crews.head); | |
| 339 | }); | |
| 247 | 340 | |
| 248 | 341 | test("a writer asking for changes on the approved head stops it running again", async () => { |
| 342 | // Invariant: a writer's changes request withdraws the approval of that head. | |
| 249 | 343 | const { pull, head } = await forkPull(carol, lib, "turned"); |
| 250 | 344 | await approve(alice, lib, pull); |
| 251 | await settle(); | |
| 252 | expect(runsFor(lib, "check", pull, head).length).toBeGreaterThan(0); | |
| 345 | await ran(lib, "check", pull, head); | |
| 253 | 346 | const turned = await submitReview(app, alice, lib, pull, { |
| 254 | 347 | state: "changes_requested", |
| 255 | 348 | body: "Not this.", |
| @@ -264,14 +357,14 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 264 | 357 | const { pull, head } = await forkPull(carol, notes, branch); |
| 265 | 358 | await approve(alice, notes, pull); |
| 266 | 359 | const env = `pull-${pull.number}`; |
| 267 | const deadline = Date.now() + 90_000; | |
| 360 | const deadline = Date.now() + 10_000; | |
| 268 | 361 | while ( |
| 269 | 362 | !listRuns(app.db, notes.id, { kind: "deploy", limit: 100 }).some( |
| 270 | 363 | (r) => r.env === env && r.commitOid === head && r.state === "ok", |
| 271 | 364 | ) |
| 272 | 365 | ) { |
| 273 | 366 | if (Date.now() > deadline) throw new Error("preview did not deploy"); |
| 274 | await Bun.sleep(200); | |
| 367 | await Bun.sleep(5); | |
| 275 | 368 | } |
| 276 | 369 | return { pull, head, env }; |
| 277 | 370 | } |
| @@ -292,6 +385,7 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 292 | 385 | } |
| 293 | 386 | |
| 294 | 387 | test("a preview asleep at a head nobody approved is taken down, not woken", async () => { |
| 388 | // Invariant: waking an asleep preview asks for approval again. | |
| 295 | 389 | const { pull, env } = await deployedPreview("sleeper"); |
| 296 | 390 | await restart(); |
| 297 | 391 | expect(envOf(env)?.state).toBe("asleep"); |
| @@ -299,17 +393,19 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 299 | 393 | app.db.run("DELETE FROM review WHERE issue_id = ?1", [pull.id]); |
| 300 | 394 | expect(await runtime.wake(notesApp, env as never)).toBeUndefined(); |
| 301 | 395 | expect(envOf(env)).toBeUndefined(); |
| 302 | }, 120_000); | |
| 396 | }, 20_000); | |
| 303 | 397 | |
| 304 | 398 | test("at boot, a preview at a head nobody approved is taken down", async () => { |
| 399 | // Invariant: boot takes down a preview whose head nobody approved. | |
| 305 | 400 | const { pull, env } = await deployedPreview("booted"); |
| 306 | 401 | // As if it had been deployed before the gate: no approval stands. |
| 307 | 402 | app.db.run("DELETE FROM review WHERE issue_id = ?1", [pull.id]); |
| 308 | 403 | await restart(); |
| 309 | 404 | expect(envOf(env)).toBeUndefined(); |
| 310 | }, 120_000); | |
| 405 | }, 20_000); | |
| 311 | 406 | |
| 312 | 407 | test("a writer asking for changes on a running preview's head takes it down at once", async () => { |
| 408 | // Invariant: a changes request takes a running reader's preview down. | |
| 313 | 409 | const { pull, env } = await deployedPreview("running"); |
| 314 | 410 | expect(["running", "deploying"]).toContain(envOf(env)?.state ?? ""); |
| 315 | 411 | const turned = await submitReview(app, alice, notes, pull, { |
| @@ -319,11 +415,12 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 319 | 415 | expect(turned.ok).toBe(true); |
| 320 | 416 | const deadline = Date.now() + 10_000; |
| 321 | 417 | while (envOf(env) !== undefined && Date.now() < deadline) |
| 322 | await Bun.sleep(100); | |
| 418 | await Bun.sleep(5); | |
| 323 | 419 | expect(envOf(env)).toBeUndefined(); |
| 324 | }, 120_000); | |
| 420 | }, 20_000); | |
| 325 | 421 | |
| 326 | 422 | test("a writer's own preview survives boot and a changes request", async () => { |
| 423 | // Invariant: a writer's own preview needs no approval to come back at boot. | |
| 327 | 424 | const mine = await commitToBranch(app, alice, notes, { |
| 328 | 425 | branch: "own", |
| 329 | 426 | message: "Own", |
| @@ -337,12 +434,12 @@ describe("a reader's fork pull runs nothing until a writer approves its head", ( | ||
| 337 | 434 | }); |
| 338 | 435 | if (!opened.ok) throw new Error(String(opened.error)); |
| 339 | 436 | const env = `pull-${opened.value.number}`; |
| 340 | const deadline = Date.now() + 90_000; | |
| 437 | const deadline = Date.now() + 10_000; | |
| 341 | 438 | while (envOf(env)?.state !== "running") { |
| 342 | 439 | if (Date.now() > deadline) throw new Error("preview did not deploy"); |
| 343 | await Bun.sleep(200); | |
| 440 | await Bun.sleep(5); | |
| 344 | 441 | } |
| 345 | 442 | await restart(); |
| 346 | 443 | expect(envOf(env)?.state).toBe("asleep"); |
| 347 | }, 120_000); | |
| 444 | }, 20_000); | |
| 348 | 445 | }); |
refusals() matches only on message + env=pull-N (+commit). Pull numbers repeat across lib and notes (both have pull-1, pull-2, ...), and the preview line carries only the app slug, not the repo. A refusal logged for notes pull-N therefore satisfies checkDecided(lib, pull-N), and the count of 2 in the crewvote test can be met by two different repos. The wait then ends early and
toHaveLength(0)is vacuous - exactly the guess the sleep made. Fix: also match the repo field (repo=alice/libfor the check line,app=<slug>for the preview line), or snapshot said.length before the action and count only lines after it, per repo. Keep the commit filter.