Public: anyone can read this; the crew can change it.
test/looks.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10269
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10258-test-looks-test-ts-runs-in-under-10-s-ev → main 1 commit fork
1 file
changed +286
−216
test/looks.test.ts +286 −216
| @@ -80,13 +80,23 @@ describe("the look", () => { | ||
| 80 | 80 | let aliceCookie: string; |
| 81 | 81 | let bobCookie: string; |
| 82 | 82 | |
| 83 | // The stand-in browser: writes a picture, or falls over, as told. | |
| 84 | let mode: "ok" | "fail" | "nobrowser" = "ok"; | |
| 83 | // The stand-in browser: writes a picture, or falls over, as told — told | |
| 84 | // per app host, since every preview of this file deploys at once. | |
| 85 | const modes = new Map<string, "fail" | "nobrowser">(); | |
| 86 | const hostOf = (input: LookInput) => new URL(input.url).hostname; | |
| 87 | /** The host the browser is sent to for one of `hello`'s environments. */ | |
| 88 | const helloHost = (env: string) => | |
| 89 | env === "production" | |
| 90 | ? `hello.${DOMAIN}` | |
| 91 | : `hello--${env.slice("pull-".length)}.${DOMAIN}`; | |
| 85 | 92 | const calls: LookInput[] = []; |
| 86 | 93 | // The dark pictures, kept apart so what counts the light ones stays true. |
| 87 | 94 | const darkCalls: LookInput[] = []; |
| 95 | /** The light pictures taken of one of `hello`'s environments. */ | |
| 96 | const callsAt = (env: string) => calls.filter((c) => hostOf(c) === helloHost(env)); | |
| 88 | 97 | const fake: Capture = async (input) => { |
| 89 | 98 | (input.dark === true ? darkCalls : calls).push(input); |
| 99 | const mode = modes.get(hostOf(input)); | |
| 90 | 100 | if (mode === "fail") return err("the browser fell over"); |
| 91 | 101 | if (mode === "nobrowser") return err(NO_BROWSER); |
| 92 | 102 | writeFileSync(input.out, input.width === 1280 ? DESKTOP_PNG : PNG); |
| @@ -125,7 +135,7 @@ describe("the look", () => { | ||
| 125 | 135 | while (Date.now() < deadline) { |
| 126 | 136 | const value = read(); |
| 127 | 137 | if (value !== undefined) return value; |
| 128 | await Bun.sleep(100); | |
| 138 | await Bun.sleep(10); | |
| 129 | 139 | } |
| 130 | 140 | throw new Error("did not happen in time"); |
| 131 | 141 | } |
| @@ -153,6 +163,86 @@ describe("the look", () => { | ||
| 153 | 163 | return rows.length === 3 ? rows : undefined; |
| 154 | 164 | }, 30_000); |
| 155 | 165 | |
| 166 | interface Opened { | |
| 167 | readonly pull: Pull; | |
| 168 | readonly commitOid: string; | |
| 169 | } | |
| 170 | ||
| 171 | /** A branch off the default with these files, and a pull of it opened by `who`. */ | |
| 172 | const openWith = async ( | |
| 173 | who: User, | |
| 174 | on: Repo, | |
| 175 | branch: string, | |
| 176 | title: string, | |
| 177 | files: { path: string; content: string }[], | |
| 178 | onOpen?: (pullNumber: number) => void, | |
| 179 | ): Promise<Opened> => { | |
| 180 | const committed = await commitToBranch(app, alice, on, { | |
| 181 | branch, | |
| 182 | base: on.defaultBranch, | |
| 183 | message: title, | |
| 184 | files, | |
| 185 | }); | |
| 186 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 187 | const opened = await openPull(app, who, on, { | |
| 188 | title, | |
| 189 | baseBranch: on.defaultBranch, | |
| 190 | headBranch: branch, | |
| 191 | }); | |
| 192 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 193 | onOpen?.(opened.value.number); | |
| 194 | return { pull: opened.value, commitOid: committed.value.commitOid }; | |
| 195 | }; | |
| 196 | ||
| 197 | // The previews the tests read, opened and pictured once in `beforeAll`. | |
| 198 | let greeting: Opened; | |
| 199 | let unpictured: Opened; | |
| 200 | let slowRed: Opened; | |
| 201 | let ask: Opened; | |
| 202 | let twoPages: Opened; | |
| 203 | let gallery: Opened & { readonly repo: Repo; readonly app: AppRecord }; | |
| 204 | /** The real-browser app, deployed only when this machine has a Chromium. */ | |
| 205 | let peek: { readonly repo: Repo; readonly app: AppRecord } | undefined; | |
| 206 | ||
| 207 | const chromium = findChromium(); | |
| 208 | ||
| 209 | /** An app that writes down who the forge said was there. */ | |
| 210 | const startPeek = async (): Promise<{ repo: Repo; app: AppRecord }> => { | |
| 211 | const made = await createRepo(app, alice, { | |
| 212 | name: "peek", | |
| 213 | visibility: "private", | |
| 214 | }); | |
| 215 | if (!made.ok) throw new Error(String(made.error)); | |
| 216 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 217 | message: "Start", | |
| 218 | files: [ | |
| 219 | { | |
| 220 | path: "package.json", | |
| 221 | content: '{"name":"peek","scripts":{"start":"bun run server.ts"}}\n', | |
| 222 | }, | |
| 223 | { | |
| 224 | path: "server.ts", | |
| 225 | content: [ | |
| 226 | 'import { appendFileSync } from "node:fs";', | |
| 227 | "Bun.serve({", | |
| 228 | ' port: Number(process.env.PORT), hostname: "127.0.0.1",', | |
| 229 | " fetch(request) {", | |
| 230 | ' const who = request.headers.get("x-forge-identity") ?? "nobody";', | |
| 231 | " appendFileSync(`${process.env.FORGE_APP_DATA}/seen.txt`, `${who}\\n`);", | |
| 232 | ' return new Response(`<!doctype html><h1>peek</h1><p>${who}</p>`, { headers: { "content-type": "text/html" } });', | |
| 233 | " },", | |
| 234 | "});", | |
| 235 | "", | |
| 236 | ].join("\n"), | |
| 237 | }, | |
| 238 | ], | |
| 239 | }); | |
| 240 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 241 | const enabled = enableApp(app, alice, made.value, { slug: "peek" }); | |
| 242 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 243 | return { repo: made.value, app: enabled.value }; | |
| 244 | }; | |
| 245 | ||
| 156 | 246 | beforeAll(async () => { |
| 157 | 247 | dataDir = await mkdtemp(join(tmpdir(), "forge-looks-")); |
| 158 | 248 | app = createApp({ dataDir, port: 0 }); |
| @@ -162,7 +252,8 @@ describe("the look", () => { | ||
| 162 | 252 | scheme: "http", |
| 163 | 253 | publicPort: servers.port, |
| 164 | 254 | forgeUrl: servers.url, |
| 165 | concurrency: 2, | |
| 255 | // Room for every preview `beforeAll` opens to deploy at once. | |
| 256 | concurrency: 8, | |
| 166 | 257 | }); |
| 167 | 258 | await runtime.start(); |
| 168 | 259 | looks = startLooks(app, { localUrl: servers.url, capture: fake }); |
| @@ -192,7 +283,117 @@ describe("the look", () => { | ||
| 192 | 283 | if (!made.ok) throw new Error(String(made.error)); |
| 193 | 284 | repo = made.value.repo; |
| 194 | 285 | record = made.value.app; |
| 195 | }, 30_000); | |
| 286 | ||
| 287 | // Every preview a test only reads is opened here, at once: their deploys | |
| 288 | // overlap instead of each test waiting out its own. | |
| 289 | [greeting, unpictured, slowRed, ask, twoPages, gallery, peek] = await Promise.all([ | |
| 290 | openWith(alice, repo, "greeting", "Say hello", [ | |
| 291 | { path: "GREETING.md", content: "hi\n" }, | |
| 292 | ]), | |
| 293 | // A pull that never had a picture: its browser falls over from the start. | |
| 294 | openWith(alice, repo, "second", "Second", [{ path: "SECOND.md", content: "2\n" }], (n) => | |
| 295 | modes.set(helloHost(previewEnv(n)), "fail"), | |
| 296 | ), | |
| 297 | openWith(alice, repo, "slow-red-test", "Slow, then red, test", [ | |
| 298 | { | |
| 299 | path: "forge.json", | |
| 300 | content: `${JSON.stringify( | |
| 301 | { | |
| 302 | app: { | |
| 303 | run: "bun run src/server.ts", | |
| 304 | // Blocks on this file until the test opens it. | |
| 305 | test: `while [ ! -e '${join(dataDir, "slow-red-test.gate")}' ]; do sleep 0.05; done; exit 1`, | |
| 306 | tty: "bun run src/tty.ts", | |
| 307 | }, | |
| 308 | }, | |
| 309 | null, | |
| 310 | 2, | |
| 311 | )}\n`, | |
| 312 | }, | |
| 313 | ]), | |
| 314 | (async () => { | |
| 315 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 316 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 317 | if (!addToCrew(app, alice, repo, "scout").ok) throw new Error("scout not crew"); | |
| 318 | return openWith(scout.value.user, repo, "ask/a", "Ask a", [ | |
| 319 | { path: "a.md", content: "a\n" }, | |
| 320 | ]); | |
| 321 | })(), | |
| 322 | openWith(alice, repo, "two-pages", "Look at two pages", [ | |
| 323 | { | |
| 324 | path: "forge.json", | |
| 325 | content: `${JSON.stringify( | |
| 326 | { | |
| 327 | app: { | |
| 328 | run: "bun run src/server.ts", | |
| 329 | test: "bun test", | |
| 330 | tty: "bun run src/tty.ts", | |
| 331 | looks: ["/", "/board"], | |
| 332 | }, | |
| 333 | }, | |
| 334 | null, | |
| 335 | 2, | |
| 336 | )}\n`, | |
| 337 | }, | |
| 338 | ]), | |
| 339 | (async () => { | |
| 340 | const madeGallery = await newApp(app, alice, { name: "gallery", visibility: "public" }); | |
| 341 | if (!madeGallery.ok) throw new Error(String(madeGallery.error)); | |
| 342 | const opened = await openWith(alice, madeGallery.value.repo, "frame", "A frame", [ | |
| 343 | { path: "FRAME.md", content: "frame\n" }, | |
| 344 | ]); | |
| 345 | return { ...opened, repo: madeGallery.value.repo, app: madeGallery.value.app }; | |
| 346 | })(), | |
| 347 | chromium === undefined ? Promise.resolve(undefined) : startPeek(), | |
| 348 | ]); | |
| 349 | ||
| 350 | // Each settled and pictured before any test reads it. | |
| 351 | const done = (env: string, commitOid: string, onRepo = repo.id) => | |
| 352 | settled((r) => r.env === env && r.commitOid === commitOid, onRepo); | |
| 353 | await Promise.all([ | |
| 354 | settled((r) => r.env === "production").then((run) => | |
| 355 | pictured("production", run.commitOid), | |
| 356 | ), | |
| 357 | ...[greeting, unpictured, ask].map(async (opened) => { | |
| 358 | await done(previewEnv(opened.pull.number), opened.commitOid); | |
| 359 | await pictured(previewEnv(opened.pull.number), opened.commitOid); | |
| 360 | }), | |
| 361 | // Held at its gate, still running: serving, and its pictures the | |
| 362 | // test's own to wait for. | |
| 363 | until(() => | |
| 364 | listEvents(app.db, { repoId: repo.id, limit: 200 }).some( | |
| 365 | (e) => e.kind === "app.serving" && e.payload["commitOid"] === slowRed.commitOid, | |
| 366 | ) | |
| 367 | ? true | |
| 368 | : undefined, | |
| 369 | ), | |
| 370 | done(previewEnv(twoPages.pull.number), twoPages.commitOid).then(() => | |
| 371 | until(() => { | |
| 372 | const env = previewEnv(twoPages.pull.number); | |
| 373 | const light = looksForEnv(app.db, record.id, env).filter( | |
| 374 | (l) => l.commitOid === twoPages.commitOid, | |
| 375 | ); | |
| 376 | const dark = looksForEnv(app.db, record.id, env, "dark").filter( | |
| 377 | (l) => l.commitOid === twoPages.commitOid, | |
| 378 | ); | |
| 379 | return light.length === 6 && dark.length === 4 ? true : undefined; | |
| 380 | }), | |
| 381 | ), | |
| 382 | done(previewEnv(gallery.pull.number), gallery.commitOid, gallery.repo.id).then(() => | |
| 383 | until(() => | |
| 384 | looksForEnv(app.db, gallery.app.id, previewEnv(gallery.pull.number)).filter( | |
| 385 | (l) => l.commitOid === gallery.commitOid, | |
| 386 | ).length === 3 | |
| 387 | ? true | |
| 388 | : undefined, | |
| 389 | ), | |
| 390 | ), | |
| 391 | peek === undefined | |
| 392 | ? undefined | |
| 393 | : settled((r) => r.env === "production", peek.repo.id), | |
| 394 | ]); | |
| 395 | await looks.idle(); | |
| 396 | }, 60_000); | |
| 196 | 397 | |
| 197 | 398 | afterAll(async () => { |
| 198 | 399 | await looks.stop(); |
| @@ -206,7 +407,8 @@ describe("the look", () => { | ||
| 206 | 407 | let first = ""; |
| 207 | 408 | let second = ""; |
| 208 | 409 | |
| 209 | test("a green deploy is pictured at all three widths, once per head — phone and desktop as the owner, the thumb as a stranger, reduced from the desktop page; a newer head replaces", async () => { | |
| 410 | test("a green production deploy is pictured at all three widths as the owner, the thumb reduced from the desktop page", async () => { | |
| 411 | // Invariant: production's head gets phone, desktop and thumb, each taken through the grant door as the owner, staged aside and moved in whole. | |
| 210 | 412 | const production = await settled((r) => r.env === "production"); |
| 211 | 413 | expect(production.state).toBe("ok"); |
| 212 | 414 | const rows = await pictured("production", production.commitOid); |
| @@ -224,17 +426,18 @@ describe("the look", () => { | ||
| 224 | 426 | } |
| 225 | 427 | // The browser was pointed at the grant door on the forge's own port, |
| 226 | 428 | // and the grant names the owner as an admin. |
| 227 | expect(calls).toHaveLength(3); | |
| 429 | const taken = callsAt("production"); | |
| 430 | expect(taken).toHaveLength(3); | |
| 228 | 431 | // Taken to one side and moved in whole: nothing half-written lives |
| 229 | 432 | // under the environment, and nothing is left behind on the side. |
| 230 | 433 | for (const call of calls) |
| 231 | 434 | expect(call.out).toContain(join("looks", ".taking")); |
| 232 | 435 | expect(readdirSync(join(dataDir, "looks", ".taking"))).toEqual([]); |
| 233 | const phone = calls.find((c) => c.width === 390)!; | |
| 436 | const phone = taken.find((c) => c.width === 390)!; | |
| 234 | 437 | expect(phone.height).toBe(844); |
| 235 | 438 | // Desktop and thumb are both taken in desktop's window: a browser does |
| 236 | 439 | // not lay a page out at a tile's size. |
| 237 | const wide = calls.filter((c) => c.width === 1280); | |
| 440 | const wide = taken.filter((c) => c.width === 1280); | |
| 238 | 441 | expect(wide.map((c) => c.height)).toEqual([800, 800]); |
| 239 | 442 | const thumb = rows.find((l) => l.kind === "thumb")!; |
| 240 | 443 | expect(pngSize(join(dataDir, thumb.path))).toEqual([640, 400]); |
| @@ -242,7 +445,7 @@ describe("the look", () => { | ||
| 242 | 445 | expect(pngSize(join(dataDir, desktop.path))).toEqual([1280, 800]); |
| 243 | 446 | // This app is private: a stranger is sent to sign in on another host, |
| 244 | 447 | // so its thumb is taken as the owner, through the same grant door (#7830). |
| 245 | const thumbCall = calls.find((c) => c.out.endsWith("-thumb.png"))!; | |
| 448 | const thumbCall = taken.find((c) => c.out.endsWith("-thumb.png"))!; | |
| 246 | 449 | const thumbGrant = readGrant( |
| 247 | 450 | dataDir, |
| 248 | 451 | new URL(thumbCall.url).searchParams.get("t") ?? "", |
| @@ -260,27 +463,17 @@ describe("the look", () => { | ||
| 260 | 463 | env: "production", |
| 261 | 464 | }); |
| 262 | 465 | expect(grant?.who).toEqual({ id: alice.id, name: "alice", role: "admin" }); |
| 466 | }); | |
| 263 | 467 | |
| 264 | // A pull's preview, the same way. | |
| 265 | const committed = await commitToBranch(app, alice, repo, { | |
| 266 | branch: "greeting", | |
| 267 | base: repo.defaultBranch, | |
| 268 | message: "Say hello", | |
| 269 | files: [{ path: "GREETING.md", content: "hi\n" }], | |
| 270 | }); | |
| 271 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 272 | first = committed.value.commitOid; | |
| 273 | const opened = await openPull(app, alice, repo, { | |
| 274 | title: "Say hello", | |
| 275 | baseBranch: repo.defaultBranch, | |
| 276 | headBranch: "greeting", | |
| 277 | }); | |
| 278 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 279 | pull = opened.value; | |
| 468 | test("a pull's preview is pictured at its own host, and what the page recorded reaches a check made afterwards", async () => { | |
| 469 | // Invariant: a green preview gets its three looks at `hello--<n>`, and the phone's LCP and desktop's CLS reach the vitals of a check created after the look. | |
| 470 | pull = greeting.pull; | |
| 471 | first = greeting.commitOid; | |
| 280 | 472 | const env = previewEnv(pull.number); |
| 281 | 473 | expect((await settled((r) => r.env === env)).state).toBe("ok"); |
| 282 | 474 | expect(await pictured(env, first)).toHaveLength(3); |
| 283 | expect(calls).toHaveLength(6); | |
| 475 | expect(callsAt(env)).toHaveLength(3); | |
| 476 | expect(new URL(callsAt(env)[0]!.url).hostname).toBe(`hello--${pull.number}.${DOMAIN}`); | |
| 284 | 477 | // The entries the browser recorded reach the check's summary: the |
| 285 | 478 | // phone's LCP and the desktop's CLS, graded; the thumb adds no row. |
| 286 | 479 | // The look finished before any check run of this commit existed; the |
| @@ -309,20 +502,23 @@ describe("the look", () => { | ||
| 309 | 502 | value: "0.3", |
| 310 | 503 | grade: "poor", |
| 311 | 504 | }); |
| 312 | expect(new URL(calls[3]!.url).hostname).toBe( | |
| 313 | `hello--${pull.number}.${DOMAIN}`, | |
| 314 | ); | |
| 505 | }); | |
| 315 | 506 | |
| 316 | // The same head green again is not pictured again. | |
| 507 | test("the same head green again is not pictured again", async () => { | |
| 508 | // Invariant: a redeploy of an already-pictured head takes no new picture and leaves its three rows. | |
| 509 | const env = previewEnv(pull.number); | |
| 317 | 510 | runtime.requestDeploy(record, repo, env, first, "redeploy"); |
| 318 | 511 | expect( |
| 319 | 512 | (await settled((r) => r.env === env && r.reason === "redeploy")).state, |
| 320 | 513 | ).toBe("ok"); |
| 321 | 514 | await looks.idle(); |
| 322 | expect(calls).toHaveLength(6); | |
| 515 | expect(callsAt(env)).toHaveLength(3); | |
| 323 | 516 | expect(looksForEnv(app.db, record.id, env)).toHaveLength(3); |
| 517 | }); | |
| 324 | 518 | |
| 325 | // A newer head is pictured, and the older head's pictures go. | |
| 519 | test("a newer head is pictured, and the older head's pictures go", async () => { | |
| 520 | // Invariant: a new green head of a preview is pictured once and replaces the older head's rows and files; production's are untouched. | |
| 521 | const env = previewEnv(pull.number); | |
| 326 | 522 | const again = await commitToBranch(app, alice, repo, { |
| 327 | 523 | branch: "greeting", |
| 328 | 524 | message: "More greeting", |
| @@ -334,17 +530,18 @@ describe("the look", () => { | ||
| 334 | 530 | (await settled((r) => r.env === env && r.commitOid === second)).state, |
| 335 | 531 | ).toBe("ok"); |
| 336 | 532 | expect(await pictured(env, second)).toHaveLength(3); |
| 337 | expect(calls).toHaveLength(9); | |
| 533 | expect(callsAt(env)).toHaveLength(6); | |
| 338 | 534 | await until(() => |
| 339 | 535 | looksForEnv(app.db, record.id, env).every((l) => l.commitOid === second) |
| 340 | 536 | ? true |
| 341 | 537 | : undefined, |
| 342 | 538 | ); |
| 343 | expect(existsSync(join(dataDir, rows[0]!.path))).toBe(true); | |
| 539 | const production = looksForEnv(app.db, record.id, "production")[0]!; | |
| 540 | expect(existsSync(join(dataDir, production.path))).toBe(true); | |
| 344 | 541 | expect( |
| 345 | 542 | existsSync(join(dataDir, "looks", record.id, env, `${first}-phone.png`)), |
| 346 | 543 | ).toBe(false); |
| 347 | }, 120_000); | |
| 544 | }); | |
| 348 | 545 | |
| 349 | 546 | test("the pull page shows both pictures; the route serves them cached, and refuses a stranger", async () => { |
| 350 | 547 | const base = `/alice/hello`; |
| @@ -431,11 +628,10 @@ describe("the look", () => { | ||
| 431 | 628 | expect(retaken.headers.get("etag")).not.toBe(etag); |
| 432 | 629 | }); |
| 433 | 630 | |
| 434 | let unpictured: Pull; | |
| 435 | ||
| 436 | 631 | test("a picture that could not be taken is recorded with its reason, and the page says so", async () => { |
| 437 | mode = "fail"; | |
| 632 | // Invariant: a failed capture is a row with its reason and no path; the pull keeps its last good pictures and says the head moved, and why. | |
| 438 | 633 | const env = `pull-${pull.number}`; |
| 634 | modes.set(helloHost(env), "fail"); | |
| 439 | 635 | const moved = await commitToBranch(app, alice, repo, { |
| 440 | 636 | branch: "greeting", |
| 441 | 637 | message: "Once more", |
| @@ -463,38 +659,27 @@ describe("the look", () => { | ||
| 463 | 659 | (await web(`/alice/hello/looks/${env}/${third}/phone.png`, aliceCookie)) |
| 464 | 660 | .status, |
| 465 | 661 | ).toBe(404); |
| 662 | modes.delete(helloHost(env)); | |
| 663 | }); | |
| 466 | 664 | |
| 467 | // A pull that never had a picture says so, without one. | |
| 468 | const branch = await commitToBranch(app, alice, repo, { | |
| 469 | branch: "second", | |
| 470 | base: repo.defaultBranch, | |
| 471 | message: "Second", | |
| 472 | files: [{ path: "SECOND.md", content: "2\n" }], | |
| 473 | }); | |
| 474 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 475 | const opened = await openPull(app, alice, repo, { | |
| 476 | title: "Second", | |
| 477 | baseBranch: repo.defaultBranch, | |
| 478 | headBranch: "second", | |
| 479 | }); | |
| 480 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 481 | unpictured = opened.value; | |
| 482 | const env2 = `pull-${unpictured.number}`; | |
| 483 | expect((await settled((r) => r.env === env2)).state).toBe("ok"); | |
| 484 | await pictured(env2, branch.value.commitOid); | |
| 665 | test("a pull that never had a picture says so, with the reason, and links none", async () => { | |
| 666 | // Invariant: a pull whose every capture failed shows the Look card's "No picture of" with the browser's reason and no picture URL. | |
| 667 | const env2 = `pull-${unpictured.pull.number}`; | |
| 668 | const rows = await pictured(env2, unpictured.commitOid); | |
| 669 | expect(rows.every((l) => l.error === "the browser fell over")).toBe(true); | |
| 485 | 670 | const none = await ( |
| 486 | await web(`/alice/hello/pulls/${unpictured.number}`, aliceCookie) | |
| 671 | await web(`/alice/hello/pulls/${unpictured.pull.number}`, aliceCookie) | |
| 487 | 672 | ).text(); |
| 488 | 673 | expect(none).toContain("<h2>Look</h2>"); |
| 489 | 674 | expect(none).toContain("No picture of"); |
| 490 | 675 | expect(none).toContain("the browser fell over"); |
| 491 | 676 | expect(none).not.toContain(`/looks/${env2}/`); |
| 492 | mode = "ok"; | |
| 493 | }, 120_000); | |
| 677 | }); | |
| 494 | 678 | |
| 495 | 679 | test("with no browser the check's summary says so and stays green", async () => { |
| 496 | mode = "nobrowser"; | |
| 497 | const env = `pull-${unpictured.number}`; | |
| 680 | // Invariant: NO_BROWSER is recorded for the check as "no browser" per look, words only, and leaves the check's state alone. | |
| 681 | const env = `pull-${unpictured.pull.number}`; | |
| 682 | modes.set(helloHost(env), "nobrowser"); | |
| 498 | 683 | const moved = await commitToBranch(app, alice, repo, { |
| 499 | 684 | branch: "second", |
| 500 | 685 | message: "No browser", |
| @@ -522,8 +707,8 @@ describe("the look", () => { | ||
| 522 | 707 | // Words only: the table has no verdict to turn a check red. |
| 523 | 708 | expect(vitalsRows(got).map((g) => g.skipped)).toEqual(["no browser", "no browser"]); |
| 524 | 709 | expect(listRuns(app.db, repo.id, { kind: "check" }).find((r) => r.id === check.id)!.state).toBe("queued"); |
| 525 | mode = "ok"; | |
| 526 | }, 120_000); | |
| 710 | modes.delete(helloHost(env)); | |
| 711 | }); | |
| 527 | 712 | |
| 528 | 713 | test("a look recorded with no theme reads as light, and light and dark never share a slot", () => { |
| 529 | 714 | const base = { |
| @@ -568,57 +753,31 @@ describe("the look", () => { | ||
| 568 | 753 | }); |
| 569 | 754 | |
| 570 | 755 | test("a closed pull's pictures go with its preview", async () => { |
| 571 | const env = `pull-${unpictured.number}`; | |
| 756 | // Invariant: closing a pull deletes its preview's look rows and folder, and its page shows no Look card. | |
| 757 | const env = `pull-${unpictured.pull.number}`; | |
| 572 | 758 | expect(looksForEnv(app.db, record.id, env).length).toBeGreaterThan(0); |
| 573 | 759 | const closed = await setIssueStateThrough( |
| 574 | 760 | app, |
| 575 | 761 | alice, |
| 576 | 762 | repo, |
| 577 | unpictured, | |
| 763 | unpictured.pull, | |
| 578 | 764 | "closed", |
| 579 | 765 | ); |
| 580 | 766 | expect(closed.ok).toBe(true); |
| 581 | 767 | expect(looksForEnv(app.db, record.id, env)).toHaveLength(0); |
| 582 | 768 | expect(existsSync(join(dataDir, "looks", record.id, env))).toBe(false); |
| 583 | 769 | const page = await ( |
| 584 | await web(`/alice/hello/pulls/${unpictured.number}`, aliceCookie) | |
| 770 | await web(`/alice/hello/pulls/${unpictured.pull.number}`, aliceCookie) | |
| 585 | 771 | ).text(); |
| 586 | 772 | expect(page).not.toContain("<h2>Look</h2>"); |
| 587 | 773 | }); |
| 588 | 774 | |
| 589 | 775 | test("a look is taken from `app.serving`, while the deploy's tests are still running, and a red check afterward does not remove it", async () => { |
| 590 | // The app's test blocks on this file until the look has been taken. | |
| 776 | // Invariant: the look is taken on `app.serving` while the deploy's test still runs, and that test failing afterward leaves the pictures. | |
| 591 | 777 | const gate = join(dataDir, "slow-red-test.gate"); |
| 592 | const branch = await commitToBranch(app, alice, repo, { | |
| 593 | branch: "slow-red-test", | |
| 594 | base: repo.defaultBranch, | |
| 595 | message: "Slow, then red, test", | |
| 596 | files: [ | |
| 597 | { | |
| 598 | path: "forge.json", | |
| 599 | content: `${JSON.stringify( | |
| 600 | { | |
| 601 | app: { | |
| 602 | run: "bun run src/server.ts", | |
| 603 | test: `while [ ! -e '${gate}' ]; do sleep 0.05; done; exit 1`, | |
| 604 | tty: "bun run src/tty.ts", | |
| 605 | }, | |
| 606 | }, | |
| 607 | null, | |
| 608 | 2, | |
| 609 | )}\n`, | |
| 610 | }, | |
| 611 | ], | |
| 612 | }); | |
| 613 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 614 | const opened = await openPull(app, alice, repo, { | |
| 615 | title: "Slow, then red, test", | |
| 616 | baseBranch: repo.defaultBranch, | |
| 617 | headBranch: "slow-red-test", | |
| 618 | }); | |
| 619 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 620 | const env = `pull-${opened.value.number}`; | |
| 621 | const commitOid = branch.value.commitOid; | |
| 778 | const opened = { value: slowRed.pull }; | |
| 779 | const env = `pull-${slowRed.pull.number}`; | |
| 780 | const commitOid = slowRed.commitOid; | |
| 622 | 781 | |
| 623 | 782 | // Pictured while the test step is held at the gate — the run is still |
| 624 | 783 | // open, checking. |
| @@ -650,10 +809,11 @@ describe("the look", () => { | ||
| 650 | 809 | expect( |
| 651 | 810 | (await setIssueStateThrough(app, alice, repo, opened.value, "closed")).ok, |
| 652 | 811 | ).toBe(true); |
| 653 | }, 30_000); | |
| 812 | }); | |
| 654 | 813 | |
| 655 | 814 | test("two events for one environment, back to back, leave one queued look — at the newer commit", async () => { |
| 656 | const before = calls.length; | |
| 815 | // Invariant: a second app.serving for an environment replaces the queued first, so the superseded commit is never pictured. | |
| 816 | const before = callsAt("production").length; | |
| 657 | 817 | const live = runtime.envStatus(record, "production"); |
| 658 | 818 | if (live === undefined) throw new Error("production is not live"); |
| 659 | 819 | const superseded = `${"0".repeat(39)}1`; |
| @@ -683,7 +843,7 @@ describe("the look", () => { | ||
| 683 | 843 | // The superseded commit was never even looked at: production's own |
| 684 | 844 | // head was already pictured (from the first test), so nothing new |
| 685 | 845 | // was captured for it either. |
| 686 | expect(calls.length).toBe(before); | |
| 846 | expect(callsAt("production").length).toBe(before); | |
| 687 | 847 | expect( |
| 688 | 848 | looksForEnv(app.db, record.id, "production").some( |
| 689 | 849 | (l) => l.commitOid === superseded, |
| @@ -692,26 +852,10 @@ describe("the look", () => { | ||
| 692 | 852 | }); |
| 693 | 853 | |
| 694 | 854 | test("the app's head shows production as a thumb", async () => { |
| 695 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 696 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 697 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); | |
| 698 | const branch = await commitToBranch(app, alice, repo, { | |
| 699 | branch: "ask/a", | |
| 700 | base: repo.defaultBranch, | |
| 701 | message: "Ask a", | |
| 702 | files: [{ path: "a.md", content: "a\n" }], | |
| 703 | }); | |
| 704 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 705 | const opened = await openPull(app, scout.value.user, repo, { | |
| 706 | title: "Ask a", | |
| 707 | baseBranch: repo.defaultBranch, | |
| 708 | headBranch: "ask/a", | |
| 709 | }); | |
| 710 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 711 | const ask = opened.value; | |
| 712 | const env = `pull-${ask.number}`; | |
| 855 | // Invariant: the studio's app head shows production's thumb, and falls back to its desktop picture when no thumb row exists. | |
| 856 | const env = `pull-${ask.pull.number}`; | |
| 713 | 857 | expect((await settled((r) => r.env === env)).state).toBe("ok"); |
| 714 | const rows = await pictured(env, branch.value.commitOid); | |
| 858 | const rows = await pictured(env, ask.commitOid); | |
| 715 | 859 | expect(rows.every((l) => l.error === null)).toBe(true); |
| 716 | 860 | |
| 717 | 861 | const studio = `${aliceCookie}; forge_mode=studio`; |
| @@ -745,40 +889,13 @@ describe("the look", () => { | ||
| 745 | 889 | expect(fallback).toContain( |
| 746 | 890 | `src="/alice/hello/looks/production/${productionDesktop.commitOid}/desktop.png?v=`, |
| 747 | 891 | ); |
| 748 | }, 120_000); | |
| 892 | }); | |
| 749 | 893 | |
| 750 | 894 | test("app.looks naming two pages pictures both, distinctly, and the pull page groups them", async () => { |
| 751 | const branch = await commitToBranch(app, alice, repo, { | |
| 752 | branch: "two-pages", | |
| 753 | base: repo.defaultBranch, | |
| 754 | message: "Look at two pages", | |
| 755 | files: [ | |
| 756 | { | |
| 757 | path: "forge.json", | |
| 758 | content: `${JSON.stringify( | |
| 759 | { | |
| 760 | app: { | |
| 761 | run: "bun run src/server.ts", | |
| 762 | test: "bun test", | |
| 763 | tty: "bun run src/tty.ts", | |
| 764 | looks: ["/", "/board"], | |
| 765 | }, | |
| 766 | }, | |
| 767 | null, | |
| 768 | 2, | |
| 769 | )}\n`, | |
| 770 | }, | |
| 771 | ], | |
| 772 | }); | |
| 773 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 774 | const opened = await openPull(app, alice, repo, { | |
| 775 | title: "Look at two pages", | |
| 776 | baseBranch: repo.defaultBranch, | |
| 777 | headBranch: "two-pages", | |
| 778 | }); | |
| 779 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 895 | // Invariant: each page `app.looks` names gets its own light and dark slots and files, grouped by page on the pull, dark folded away. | |
| 896 | const opened = { value: twoPages.pull }; | |
| 780 | 897 | const env = `pull-${opened.value.number}`; |
| 781 | const commitOid = branch.value.commitOid; | |
| 898 | const commitOid = twoPages.commitOid; | |
| 782 | 899 | expect((await settled((r) => r.env === env)).state).toBe("ok"); |
| 783 | 900 | |
| 784 | 901 | const rows = await until(() => { |
| @@ -881,7 +998,7 @@ describe("the look", () => { | ||
| 881 | 998 | ).text(); |
| 882 | 999 | expect(after).not.toContain(stale); |
| 883 | 1000 | expect(after).toContain(`${commitOid}/phone-dark.png?v=`); |
| 884 | }, 120_000); | |
| 1001 | }); | |
| 885 | 1002 | |
| 886 | 1003 | test("a private app's thumb taken as a stranger by an older forge is deleted on boot, to be retaken", async () => { |
| 887 | 1004 | const old = "c".repeat(40); |
| @@ -965,11 +1082,11 @@ describe("the look", () => { | ||
| 965 | 1082 | join(dataDir, thumb.path), |
| 966 | 1083 | Buffer.from(encodePng(200, 125, new Uint8Array(200 * 125 * 4))), |
| 967 | 1084 | ); |
| 968 | const before = calls.length; | |
| 1085 | const before = callsAt("production").length; | |
| 969 | 1086 | looks.sweep(); |
| 970 | 1087 | await looks.idle(); |
| 971 | expect(calls).toHaveLength(before + 1); | |
| 972 | const retaken = calls.at(-1)!; | |
| 1088 | expect(callsAt("production")).toHaveLength(before + 1); | |
| 1089 | const retaken = callsAt("production").at(-1)!; | |
| 973 | 1090 | expect(retaken.out.endsWith("-thumb.png")).toBe(true); |
| 974 | 1091 | expect([retaken.width, retaken.height]).toEqual([1280, 800]); |
| 975 | 1092 | expect( |
| @@ -983,7 +1100,7 @@ describe("the look", () => { | ||
| 983 | 1100 | // A whole set: nothing to take. |
| 984 | 1101 | looks.sweep(); |
| 985 | 1102 | await looks.idle(); |
| 986 | expect(calls).toHaveLength(before + 1); | |
| 1103 | expect(callsAt("production")).toHaveLength(before + 1); | |
| 987 | 1104 | |
| 988 | 1105 | // A picture that failed at this head had its turn: the sweep leaves |
| 989 | 1106 | // it to the next deploy rather than starting a browser every time. |
| @@ -997,8 +1114,8 @@ describe("the look", () => { | ||
| 997 | 1114 | }); |
| 998 | 1115 | looks.sweep(); |
| 999 | 1116 | await looks.idle(); |
| 1000 | expect(calls).toHaveLength(before + 1); | |
| 1001 | }, 60_000); | |
| 1117 | expect(callsAt("production")).toHaveLength(before + 1); | |
| 1118 | }); | |
| 1002 | 1119 | |
| 1003 | 1120 | test("pictures are shown only to those allowed to see them (#727): on a public repository hosting a private app, a stranger gets none, a signed-in reader the thumb, a writer all", async () => { |
| 1004 | 1121 | // `hello` is a private app; its repository is made public here. |
| @@ -1094,27 +1211,13 @@ describe("the look", () => { | ||
| 1094 | 1211 | }); |
| 1095 | 1212 | |
| 1096 | 1213 | test("a public app's previews are taken as nobody, so a reader of its pull sees the Look card; a picture taken as the owner stays the writers' (#738)", async () => { |
| 1097 | const made = await newApp(app, alice, { name: "gallery", visibility: "public" }); | |
| 1098 | if (!made.ok) throw new Error(String(made.error)); | |
| 1099 | const gallery = made.value.repo; | |
| 1100 | const galleryApp = made.value.app; | |
| 1214 | // Invariant: a public app's preview pictures carry no grant identity and are served to readers and strangers; owner-taken ones stay the writers'. | |
| 1215 | const galleryApp = gallery.app; | |
| 1101 | 1216 | expect(galleryApp.access).toBe("public"); |
| 1102 | const branch = await commitToBranch(app, alice, gallery, { | |
| 1103 | branch: "frame", | |
| 1104 | base: gallery.defaultBranch, | |
| 1105 | message: "A frame", | |
| 1106 | files: [{ path: "FRAME.md", content: "frame\n" }], | |
| 1107 | }); | |
| 1108 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 1109 | const opened = await openPull(app, alice, gallery, { | |
| 1110 | title: "A frame", | |
| 1111 | baseBranch: gallery.defaultBranch, | |
| 1112 | headBranch: "frame", | |
| 1113 | }); | |
| 1114 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 1217 | const opened = { value: gallery.pull }; | |
| 1115 | 1218 | const env = previewEnv(opened.value.number); |
| 1116 | const commit = branch.value.commitOid; | |
| 1117 | expect((await settled((r) => r.env === env, gallery.id)).state).toBe("ok"); | |
| 1219 | const commit = gallery.commitOid; | |
| 1220 | expect((await settled((r) => r.env === env, gallery.repo.id)).state).toBe("ok"); | |
| 1118 | 1221 | const rows = await until(() => { |
| 1119 | 1222 | const found = looksForEnv(app.db, galleryApp.id, env).filter( |
| 1120 | 1223 | (l) => l.commitOid === commit, |
| @@ -1152,7 +1255,7 @@ describe("the look", () => { | ||
| 1152 | 1255 | await web(`/alice/gallery/pulls/${opened.value.number}`, bobCookie) |
| 1153 | 1256 | ).text(); |
| 1154 | 1257 | expect(after).not.toContain("<h2>Look</h2>"); |
| 1155 | }, 120_000); | |
| 1258 | }); | |
| 1156 | 1259 | |
| 1157 | 1260 | test("a public app made private after its pictures were taken: a reader gets them no more, and no Look card; its writer still does (#845)", async () => { |
| 1158 | 1261 | // Its own app, branch, pull and pictures: no other test's capture. |
| @@ -1266,52 +1369,19 @@ describe("the look", () => { | ||
| 1266 | 1369 | expect(memoryPressure(stat, "max\n", "3400000000\n")).toBeUndefined(); |
| 1267 | 1370 | }); |
| 1268 | 1371 | |
| 1269 | const chromium = findChromium(); | |
| 1270 | 1372 | test.skipIf(chromium === undefined)( |
| 1271 | 1373 | chromium === undefined |
| 1272 | 1374 | ? "a real capture — skipped: no chromium on this machine (install one, or set FORGE_CHROMIUM)" |
| 1273 | 1375 | : `a real capture through the grant door, with ${chromium}`, |
| 1274 | 1376 | async () => { |
| 1275 | // An app that writes down who the forge said was there. | |
| 1276 | const made = await createRepo(app, alice, { | |
| 1277 | name: "peek", | |
| 1278 | visibility: "private", | |
| 1279 | }); | |
| 1280 | if (!made.ok) throw new Error(String(made.error)); | |
| 1281 | const peek = made.value; | |
| 1282 | const seeded = await commitToBranch(app, alice, peek, { | |
| 1283 | message: "Start", | |
| 1284 | files: [ | |
| 1285 | { | |
| 1286 | path: "package.json", | |
| 1287 | content: | |
| 1288 | '{"name":"peek","scripts":{"start":"bun run server.ts"}}\n', | |
| 1289 | }, | |
| 1290 | { | |
| 1291 | path: "server.ts", | |
| 1292 | content: [ | |
| 1293 | 'import { appendFileSync } from "node:fs";', | |
| 1294 | "Bun.serve({", | |
| 1295 | ' port: Number(process.env.PORT), hostname: "127.0.0.1",', | |
| 1296 | " fetch(request) {", | |
| 1297 | ' const who = request.headers.get("x-forge-identity") ?? "nobody";', | |
| 1298 | " appendFileSync(`${process.env.FORGE_APP_DATA}/seen.txt`, `${who}\\n`);", | |
| 1299 | ' return new Response(`<!doctype html><h1>peek</h1><p>${who}</p>`, { headers: { "content-type": "text/html" } });', | |
| 1300 | " },", | |
| 1301 | "});", | |
| 1302 | "", | |
| 1303 | ].join("\n"), | |
| 1304 | }, | |
| 1305 | ], | |
| 1306 | }); | |
| 1307 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 1308 | const enabled = enableApp(app, alice, peek, { slug: "peek" }); | |
| 1309 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 1377 | // Invariant: a real Chromium sent through the grant door pictures the page at the window's size, records every entry type, and arrives as the owner. | |
| 1378 | if (peek === undefined) throw new Error("no peek without a browser"); | |
| 1379 | const enabled = { value: peek.app }; | |
| 1310 | 1380 | expect( |
| 1311 | (await settled((r) => r.env === "production", peek.id)).state, | |
| 1381 | (await settled((r) => r.env === "production", peek.repo.id)).state, | |
| 1312 | 1382 | ).toBe("ok"); |
| 1313 | 1383 | // The very URL the service builds: the grant door, on this listener. |
| 1314 | const door = openEnvUrl(app, alice, peek, "production"); | |
| 1384 | const door = openEnvUrl(app, alice, peek.repo, "production"); | |
| 1315 | 1385 | if (!door.ok) throw new Error(String(door.error)); |
| 1316 | 1386 | const out = join(dataDir, "real-phone.png"); |
| 1317 | 1387 | const taken = await takeLook({ |