Public: anyone can read this; the crew can change it.

The look is taken when the preview starts serving, not when its tests finish #45

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/40-the-look-is-taken-when-the-preview-start → main 1 commit fork

  • Merged. scout/40-the-look-is-taken-when-the-preview-start was merged into main as 48d86e8.

Review

Sign in to review this change.
5 files changed +179 −5
src/apps/runtime.ts +19
@@ -674,6 +674,25 @@ export class AppsRuntime {
674674 ? `serving ${url} — ${elapsedSeconds(deployStartedAt, servingAt)} after the deploy started`
675675 : `serving ${url}`,
676676 );
677 // The same statement the log line above just made, on the bus: a
678 // subscriber (the looks service) must never be able to take the
679 // deploy path down with it.
680 try {
681 this.app.bus.append({
682 kind: "app.serving",
683 repoId: repo.id,
684 payload: {
685 app: state.app.slug,
686 env: state.env,
687 commitOid: live.commitOid,
688 servingAt,
689 ...(deployStartedAt === null ? {} : { deployStartedAt }),
690 testsRunning: live.manifest.test !== null,
691 },
692 });
693 } catch (error) {
694 log.error("could not publish app.serving", { error: String(error) });
695 }
677696
678697 const tested = await outcome.tested;
679698 state.deploying = undefined;
src/design/activity.ts +2
@@ -52,6 +52,8 @@ export function eventVerb(event: ForgeEvent): string {
5252 ? `put ${who} on the crew`
5353 : `took ${who} off the crew`;
5454 }
55 if (kind === "app.serving")
56 return `started serving ${environment(payload["env"])}`;
5557 if (kind === "app.stopped") return `stopped ${environment(payload["env"])}`;
5658 if (kind === "app.env_set" || kind === "app.env_deleted") {
5759 const name = payload["name"];
src/looks/service.ts +32 −5
@@ -1,8 +1,11 @@
11 /**
2 * The look (docs/APPS.md): when an environment's deploy comes up green,
3 * the forge takes a picture of it at phone and desktop width, as the
2 * The look (docs/APPS.md): when an environment's deploy answers and swaps
3 * in, the forge takes a picture of it at phone and desktop width, as the
44 * repository's owner would see it, and keeps the pictures beside the
5 * commit they are of. A person judges a change from what it is.
5 * commit they are of. A person judges a change from what it is — the
6 * moment it starts serving, not the moment its tests, still running
7 * beside it, happen to finish (and a preview whose tests then fail keeps
8 * the picture it already has).
69 *
710 * Best-effort by design: a look never blocks a check, never fails a run,
811 * and a picture that could not be taken is a row that says why, so a page
@@ -129,6 +132,23 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
129132 if (stopped || event.repoId === null) return;
130133 const payload = event.payload;
131134 switch (event.kind) {
135 // The instant a deploy answers and swaps in — the look is of what
136 // is serving, not of whether its tests, still running beside it,
137 // turn out green.
138 case "app.serving": {
139 const env = payload["env"];
140 const commitOid = payload["commitOid"];
141 if (typeof env !== "string" || !isEnvName(env)) return;
142 if (typeof commitOid !== "string" || commitOid === "") return;
143 const record = getAppByRepo(app.db, event.repoId);
144 if (!record) return;
145 enqueue({ appId: record.id, env, commitOid, at: Date.now() });
146 return;
147 }
148 // Kept for an environment brought up by a forge from before this
149 // event existed: its deploy never published `app.serving`, so this
150 // is the only signal it has. For one deployed since, `app.serving`
151 // already queued the same commit — `enqueue` below dedupes it.
132152 case "run.finished": {
133153 if (payload["kind"] !== "deploy" || payload["state"] !== "ok") return;
134154 const env = payload["env"];
@@ -153,12 +173,19 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
153173 }
154174 }
155175
156 /** One waiting look per environment: a newer head takes the older's place. */
176 /**
177 * One waiting look per environment: a newer head takes the older's
178 * place. The same head queued twice — `app.serving` and then
179 * `run.finished` for the one deploy — is one entry, not two.
180 */
157181 function enqueue(item: Pending): void {
158182 const at = queue.findIndex(
159183 (q) => q.appId === item.appId && q.env === item.env,
160184 );
161 if (at !== -1) queue.splice(at, 1);
185 if (at !== -1) {
186 if (queue[at]!.commitOid === item.commitOid) return;
187 queue.splice(at, 1);
188 }
162189 queue.push(item);
163190 if (pumping) return;
164191 pumping = true;
test/apps.test.ts +22
@@ -35,6 +35,7 @@ import {
3535 updateRepoSettings,
3636 } from "../src/actions/repos.ts";
3737 import type { AppRecord } from "../src/core/apps.ts";
38 import { listEvents } from "../src/core/events.ts";
3839 import { getPull, type Pull } from "../src/core/pulls.ts";
3940 import { getRepoById, type Repo } from "../src/core/repos.ts";
4041 import { resolveOid } from "../src/git/merge.ts";
@@ -557,6 +558,18 @@ describe("apps: production, previews, data, gates, crew", () => {
557558 // finished, so `servingAt` is already recorded on the still-open run.
558559 expect(run?.servingAt).not.toBeNull();
559560
561 // `app.serving` published the same instant, with the tests it started
562 // beside still going — this is what a preview's look is taken from.
563 const serving = listEvents(app.db, { repoId: repo.id, limit: 200 }).find(
564 (e) =>
565 e.kind === "app.serving" &&
566 e.payload["env"] === `pull-${opened.value.number}` &&
567 e.payload["commitOid"] === committed.value.commitOid,
568 );
569 expect(serving).toBeDefined();
570 expect(serving?.payload["servingAt"]).toBe(run?.servingAt);
571 expect(serving?.payload["testsRunning"]).toBe(true);
572
560573 const done = await settled(
561574 (r) =>
562575 r.env === `pull-${opened.value.number}` &&
@@ -692,6 +705,15 @@ describe("apps: production, previews, data, gates, crew", () => {
692705 const failed = await settled(latest(`pull-${opened.value.number}`));
693706 expect(failed.state).toBe("failed");
694707 expect(failed.servingAt).toBeNull();
708 // It never swapped in, so it never published the event a look is
709 // taken from — the same reason it is pictureless.
710 expect(
711 listEvents(app.db, { repoId: repo.id, limit: 200 }).some(
712 (e) =>
713 e.kind === "app.serving" &&
714 e.payload["env"] === `pull-${opened.value.number}`,
715 ),
716 ).toBe(false);
695717
696718 // Close it — a pull that never comes up is not this test's to leave
697719 // open for the next one, which counts what git still has open.
test/looks.test.ts +104
@@ -23,6 +23,7 @@ import { envDataDir, previewEnv } from "../src/apps/data.ts";
2323 import { readGrant } from "../src/apps/proxy.ts";
2424 import { AppsRuntime } from "../src/apps/runtime.ts";
2525 import type { AppRecord } from "../src/core/apps.ts";
26 import { listEvents } from "../src/core/events.ts";
2627 import { looksForEnv, type Look } from "../src/core/looks.ts";
2728 import type { Pull } from "../src/core/pulls.ts";
2829 import type { Repo } from "../src/core/repos.ts";
@@ -390,6 +391,109 @@ describe("the look", () => {
390391 expect(page).not.toContain("<h2>Look</h2>");
391392 });
392393
394 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 () => {
395 const branch = await commitToBranch(app, alice, repo, {
396 branch: "slow-red-test",
397 base: repo.defaultBranch,
398 message: "Slow, then red, test",
399 files: [
400 {
401 path: "forge.json",
402 content: `${JSON.stringify(
403 {
404 app: {
405 run: "bun run src/server.ts",
406 test: "sleep 2 && exit 1",
407 tty: "bun run src/tty.ts",
408 },
409 },
410 null,
411 2,
412 )}\n`,
413 },
414 ],
415 });
416 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
417 const opened = await openPull(app, alice, repo, {
418 title: "Slow, then red, test",
419 baseBranch: repo.defaultBranch,
420 headBranch: "slow-red-test",
421 });
422 if (!opened.ok) throw new Error(String(opened.error));
423 const env = `pull-${opened.value.number}`;
424 const commitOid = branch.value.commitOid;
425
426 // Pictured well before the two-second test step could have finished —
427 // the run is still open, checking.
428 const rows = await pictured(env, commitOid);
429 expect(rows.every((l) => l.error === null)).toBe(true);
430 const run = listRuns(app.db, repo.id, { env }).find(
431 (r) => r.commitOid === commitOid,
432 );
433 expect(run?.state).toBe("running");
434 const serving = listEvents(app.db, { repoId: repo.id, limit: 200 }).find(
435 (e) => e.kind === "app.serving" && e.payload["commitOid"] === commitOid,
436 );
437 expect(serving?.payload["env"]).toBe(env);
438 expect(serving?.payload["testsRunning"]).toBe(true);
439
440 // The test then finishes red; the run fails, but the picture already
441 // taken of what is serving is untouched.
442 const failed = await settled(
443 (r) => r.env === env && r.commitOid === commitOid,
444 );
445 expect(failed.state).toBe("failed");
446 expect(
447 looksForEnv(app.db, record.id, env).filter(
448 (l) => l.commitOid === commitOid && l.error === null,
449 ),
450 ).toHaveLength(2);
451
452 expect(
453 (await setIssueStateThrough(app, alice, repo, opened.value, "closed"))
454 .ok,
455 ).toBe(true);
456 }, 30_000);
457
458 test("two events for one environment, back to back, leave one queued look — at the newer commit", async () => {
459 const before = calls.length;
460 const live = runtime.envStatus(record, "production");
461 if (live === undefined) throw new Error("production is not live");
462 const superseded = `${"0".repeat(39)}1`;
463 // Same tick, before the queue's pump has run at all: the second
464 // replaces the first in `enqueue` rather than both being taken.
465 app.bus.append({
466 kind: "app.serving",
467 repoId: repo.id,
468 payload: {
469 env: "production",
470 commitOid: superseded,
471 servingAt: new Date().toISOString(),
472 testsRunning: true,
473 },
474 });
475 app.bus.append({
476 kind: "app.serving",
477 repoId: repo.id,
478 payload: {
479 env: "production",
480 commitOid: live.commitOid,
481 servingAt: new Date().toISOString(),
482 testsRunning: false,
483 },
484 });
485 await looks.idle();
486 // The superseded commit was never even looked at: production's own
487 // head was already pictured (from the first test), so nothing new
488 // was captured for it either.
489 expect(calls.length).toBe(before);
490 expect(
491 looksForEnv(app.db, record.id, "production").some(
492 (l) => l.commitOid === superseded,
493 ),
494 ).toBe(false);
495 });
496
393497 test("the studio's look ask leads with the phone picture, and the app's head shows production", async () => {
394498 const scout = await createAgent(app, alice, { username: "scout" });
395499 if (!scout.ok) throw new Error(String(scout.error));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs