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

The studio sees a preview the instant it serves: listen for `app.serving` #89

Merged opened this

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

Merged

scout/forge:scout/81-the-studio-sees-a-preview-the-instant-it → main 1 commit fork

  • Merged. scout/81-the-studio-sees-a-preview-the-instant-it was merged into main as 8757c4a.

Review

Sign in to review this change.
3 files changed +78 −2
src/web/live.ts +10 −2
@@ -135,6 +135,14 @@ export const liveClientJs = /* js */ `
135135 return isApp && data && data.kind && data.kind.indexOf('run.') === 0 &&
136136 data.payload && data.payload.kind === 'deploy';
137137 }
138 // 'app.serving' fires at the swap-in — the instant a preview answers —
139 // which is seconds to half a minute before its tests (and so its
140 // run.finished) are done. It carries no run.kind, so it is matched on its
141 // own kind and on the app/env it names, never on a deploy run's shape.
142 function isServing(data) {
143 return isApp && data && data.kind === 'app.serving' && data.payload &&
144 typeof data.payload.app === 'string' && typeof data.payload.env === 'string';
145 }
138146 function refreshApp() {
139147 if (appBusy) { appQueued = true; return; }
140148 appBusy = true;
@@ -160,7 +168,7 @@ export const liveClientJs = /* js */ `
160168 function onEvent(e) {
161169 var data;
162170 try { data = JSON.parse(e.data); } catch (_) { return; }
163 if (isDeploy(data)) { refreshApp(); return; }
171 if (isDeploy(data) || isServing(data)) { refreshApp(); return; }
164172 if (relevant(data) && conv && (data.kind === thread + '.commented')) {
165173 appendNew();
166174 } else if (relevant(data) || !thread) {
@@ -175,7 +183,7 @@ export const liveClientJs = /* js */ `
175183 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed',
176184 'branch.created', 'branch.deleted',
177185 'label.created', 'label.updated', 'label.deleted',
178 'run.queued', 'run.started', 'run.finished',
186 'run.queued', 'run.started', 'run.finished', 'app.serving',
179187 'app.created', 'app.updated', 'app.deleted', 'app.stopped',
180188 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
181189 ];
test/live-pages.test.ts +4
@@ -104,6 +104,10 @@ describe("live browsing pages", () => {
104104 expect(html).toContain('id="live"');
105105 expect(html).toContain('data-app="1"');
106106 expect(html).toContain("new EventSource");
107 // `app.serving` fires at the swap-in, seconds to half a minute before
108 // the deploy run's own `run.finished` — the client must listen for it
109 // directly, not only learn a preview is live once its tests are done.
110 expect(html).toContain("app.serving");
107111 });
108112
109113 test("the studio home wires up asks, apps, now and on-you as live regions, signed in; a signed-out visitor gets neither", async () => {
test/studio.test.ts +64
@@ -34,6 +34,7 @@ import {
3434 claimRun,
3535 createRun,
3636 finishRun,
37 getRun,
3738 markServing,
3839 noteRunProgress,
3940 } from "../src/core/runs.ts";
@@ -1466,6 +1467,69 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
14661467 expect(live).toContain(">live<");
14671468 expect(live).toContain(link);
14681469 });
1470
1471 test("app.serving reaches the repo's stream with the shape the live client keys on, the instant the swap-in happens — before its run has finished", async () => {
1472 const deployRun = createRun(app.db, {
1473 repoId: repo.id,
1474 kind: "deploy",
1475 env: "production",
1476 commitOid: "deadbeef",
1477 reason: "push",
1478 });
1479 claimRun(app.db, deployRun.id);
1480 const served = markServing(app.db, deployRun.id);
1481 expect(served?.servingAt).toBeDefined();
1482
1483 const stream = await fetch(`${base}/alice/vault/stream`, {
1484 headers: { cookie: owner },
1485 });
1486 await Bun.sleep(50);
1487
1488 // The exact append `AppsRuntime` makes at the swap-in (runtime.ts),
1489 // republished here so the test does not have to spin up a real deploy.
1490 app.bus.append({
1491 kind: "app.serving",
1492 repoId: repo.id,
1493 payload: {
1494 app: record.slug,
1495 env: "production",
1496 commitOid: deployRun.commitOid,
1497 servingAt: served!.servingAt,
1498 testsRunning: true,
1499 },
1500 });
1501
1502 const reader = stream.body!.getReader();
1503 const decoder = new TextDecoder();
1504 let buffer = "";
1505 let event: Record<string, unknown> | undefined;
1506 const deadline = Date.now() + 5_000;
1507 while (!event && Date.now() < deadline) {
1508 const { done, value } = await reader.read();
1509 if (done) break;
1510 buffer += decoder.decode(value);
1511 const frames = buffer.split("\n\n");
1512 buffer = frames.pop() ?? "";
1513 for (const frame of frames) {
1514 if (!/^event: app\.serving$/m.test(frame)) continue;
1515 const data = /^data: (.*)$/m.exec(frame)?.[1];
1516 if (data !== undefined) event = JSON.parse(data);
1517 }
1518 }
1519 await reader.cancel().catch(() => {});
1520 expect(event).toBeDefined();
1521 expect(event?.["kind"]).toBe("app.serving");
1522 const payload = event?.["payload"] as Record<string, unknown>;
1523 expect(payload["app"]).toBe(record.slug);
1524 expect(payload["env"]).toBe("production");
1525 expect(payload["commitOid"]).toBe(deployRun.commitOid);
1526 expect(payload["servingAt"]).toBe(served!.servingAt);
1527 expect(payload["testsRunning"]).toBe(true);
1528
1529 // finishRun never ran: run.finished has not fired, yet app.serving
1530 // already has. That gap is the whole point of the issue.
1531 expect(getRun(app.db, deployRun.id)?.state).toBe("running");
1532 });
14691533 });
14701534
14711535 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs