Public: anyone can read this; the crew can change it.
Dev's App page goes still the moment nothing is moving: a push lands and the page never says so #301
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/265-dev-s-app-page-goes-still-the-moment-not → main 1 commit fork
2 files
changed +65
−9
src/web/pages/apps.ts +1 −8
| @@ -36,7 +36,6 @@ import { | ||
| 36 | 36 | listReposVisibleTo, |
| 37 | 37 | type Repo, |
| 38 | 38 | } from "../../core/repos.ts"; |
| 39 | import { isTerminal } from "../../core/runs.ts"; | |
| 40 | 39 | import type { User } from "../../core/users.ts"; |
| 41 | 40 | import { safeBack, type Ctx } from "../../http/context.ts"; |
| 42 | 41 | import { resolveAppHostname } from "../../apps/proxy.ts"; |
| @@ -659,12 +658,6 @@ export function appPage(ctx: Ctx): Response { | ||
| 659 | 658 | runs.map((run) => runRow(base, run, actors, issues)), |
| 660 | 659 | { head: runsHead }, |
| 661 | 660 | ); |
| 662 | // Something still moving is news the live pill announces; the page is | |
| 663 | // never pulled out from under a reader. | |
| 664 | const moving = | |
| 665 | envs.some((env) => env.state === "deploying") || | |
| 666 | runs.some((run) => !isTerminal(run.state)); | |
| 667 | ||
| 668 | 661 | const environmentsBlock = !serving |
| 669 | 662 | ? card( |
| 670 | 663 | html`<p class="muted small"> |
| @@ -760,7 +753,7 @@ export function appPage(ctx: Ctx): Response { | ||
| 760 | 753 | </div> |
| 761 | 754 | <aside>${about} ${crewCard}</aside> |
| 762 | 755 | </div> |
| 763 | ${when(moving, () => liveMount({ repo: base }))}`; | |
| 756 | ${liveMount({ repo: base })}`; | |
| 764 | 757 | |
| 765 | 758 | return page({ |
| 766 | 759 | ctx, |
test/web-apps.test.ts +64 −1
| @@ -26,7 +26,7 @@ import { listAppEnvNames } from "../src/core/app_env.ts"; | ||
| 26 | 26 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 27 | 27 | import { getIssue } from "../src/core/issues.ts"; |
| 28 | 28 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 29 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 29 | import { isTerminal, listRuns, type Run } from "../src/core/runs.ts"; | |
| 30 | 30 | import { getUserByName, type User } from "../src/core/users.ts"; |
| 31 | 31 | import { serve, type Servers } from "../src/http/server.ts"; |
| 32 | 32 | import { setLogLevel } from "../src/kernel/log.ts"; |
| @@ -80,6 +80,35 @@ describe("apps, runs and the crew on the web", () => { | ||
| 80 | 80 | return words(await response.text()); |
| 81 | 81 | }; |
| 82 | 82 | |
| 83 | /** Read from an SSE body until `predicate(buffer)` holds or we time out. */ | |
| 84 | const readUntil = async ( | |
| 85 | body: ReadableStream<Uint8Array>, | |
| 86 | predicate: (buf: string) => boolean, | |
| 87 | timeoutMs = 10_000, | |
| 88 | ): Promise<string> => { | |
| 89 | const reader = body.getReader(); | |
| 90 | const decoder = new TextDecoder(); | |
| 91 | let buf = ""; | |
| 92 | const deadline = Date.now() + timeoutMs; | |
| 93 | try { | |
| 94 | while (Date.now() < deadline) { | |
| 95 | const timeout = new Promise<{ done: true; value: undefined }>((r) => | |
| 96 | setTimeout( | |
| 97 | () => r({ done: true, value: undefined }), | |
| 98 | Math.max(0, deadline - Date.now()), | |
| 99 | ), | |
| 100 | ); | |
| 101 | const { done, value } = await Promise.race([reader.read(), timeout]); | |
| 102 | if (done) break; | |
| 103 | if (value) buf += decoder.decode(value, { stream: true }); | |
| 104 | if (predicate(buf)) break; | |
| 105 | } | |
| 106 | } finally { | |
| 107 | reader.cancel().catch(() => {}); | |
| 108 | } | |
| 109 | return buf; | |
| 110 | }; | |
| 111 | ||
| 83 | 112 | const signUp = async (username: string): Promise<string> => { |
| 84 | 113 | const response = await fetch(`${base}/signup`, { |
| 85 | 114 | method: "POST", |
| @@ -230,6 +259,40 @@ describe("apps, runs and the crew on the web", () => { | ||
| 230 | 259 | expect(readers).not.toContain('href="/alice/hello/settings/app"'); |
| 231 | 260 | }); |
| 232 | 261 | |
| 262 | test("the App page mounts the live stream on a quiet app, so a push after it was drawn still shows up", async () => { | |
| 263 | // Quiet: every run on hello has already finished, nothing is deploying. | |
| 264 | await runsOf(hello, () => true); | |
| 265 | const deadline = Date.now() + 10_000; | |
| 266 | while (Date.now() < deadline) { | |
| 267 | const runs = listRuns(app.db, hello.id, { limit: 50 }); | |
| 268 | if (runs.every((run) => isTerminal(run.state))) break; | |
| 269 | await Bun.sleep(50); | |
| 270 | } | |
| 271 | const html = await bodyOf(alice, "/alice/hello/app"); | |
| 272 | expect(html).toContain('id="live"'); | |
| 273 | expect(html).toContain('data-repo="/alice/hello"'); | |
| 274 | ||
| 275 | // A quiet page still opens the stream: the redeploy fired after the | |
| 276 | // page was drawn reaches it, exactly what a still-page never saw. | |
| 277 | const res = await fetch(`${base}/alice/hello/stream`, { | |
| 278 | headers: { cookie: alice }, | |
| 279 | }); | |
| 280 | expect(res.status).toBe(200); | |
| 281 | const redeployed = await web( | |
| 282 | alice, | |
| 283 | "/alice/hello/app/production/redeploy", | |
| 284 | { form: {} }, | |
| 285 | ); | |
| 286 | expect(redeployed.status).toBe(303); | |
| 287 | const buf = await readUntil( | |
| 288 | res.body!, | |
| 289 | (b) => b.includes("event: run.started") || b.includes("event: app.serving"), | |
| 290 | ); | |
| 291 | expect( | |
| 292 | buf.includes("event: run.started") || buf.includes("event: app.serving"), | |
| 293 | ).toBe(true); | |
| 294 | }, 20_000); | |
| 295 | ||
| 233 | 296 | test("the runs list, one run's log page, and the short door to it", async () => { |
| 234 | 297 | const [run] = await runsOf(hello); |
| 235 | 298 | expect(run).toBeDefined(); |
new above
offline