Public: anyone can read this; the crew can change it.
"Now" says nothing while the app is plainly coming alive #168
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/163-now-says-nothing-while-the-app-is-plainl → main 1 commit fork
3 files
changed +156
−3
src/web/studio/app.ts +3 −1
| @@ -335,7 +335,9 @@ export async function studioApp( | ||
| 335 | 335 | nowRows.length === 0 |
| 336 | 336 | ? card( |
| 337 | 337 | html`<p class="muted small"> |
| 338 | Nothing yet. Tell the crew something. | |
| 338 | ${deployTailing | |
| 339 | ? "Production is coming up — the log above has it." | |
| 340 | : "Nothing yet. Tell the crew something."} | |
| 339 | 341 | </p>`, |
| 340 | 342 | { |
| 341 | 343 | head: html`${icon("activity")} |
src/web/studio/previews.ts +3 −2
| @@ -125,8 +125,9 @@ function previewsPanel( | ||
| 125 | 125 | * its row — the app's own first thirty seconds are not a blank page. The |
| 126 | 126 | * agent's run wins the one stream this page opens when both are going: it |
| 127 | 127 | * is the crew's own answer to something asked, where a deploy is often |
| 128 | * background (a redeploy after an unrelated push), and it is already what | |
| 129 | * "Now" shows. | |
| 128 | * background (a redeploy after an unrelated push). When "Now" would | |
| 129 | * otherwise sit empty, its own empty state points back up at this tail | |
| 130 | * rather than opening a second stream of the same lines. | |
| 130 | 131 | */ |
| 131 | 132 | function deployTail( |
| 132 | 133 | ctx: Ctx, |
test/studio.test.ts +150
| @@ -3286,6 +3286,12 @@ describe("the app's page tails the deploy that is bringing it alive", () => { | ||
| 3286 | 3286 | // No script, no problem: the page keeps refreshing while the deploy |
| 3287 | 3287 | // goes — the actual meta tag, not just the script's own selector text. |
| 3288 | 3288 | expect(page).toMatch(/<meta\s+http-equiv="refresh"/); |
| 3289 | // Issue #163: the deploy is plainly going, in the head card above — "Now" | |
| 3290 | // does not also claim nothing is happening, and it does not open a | |
| 3291 | // second stream of the same lines to say so. | |
| 3292 | expect(page).not.toContain("Nothing yet"); | |
| 3293 | expect(page).toContain("Production is coming up"); | |
| 3294 | expect(page.match(/id="app-run-tail"/g)?.length).toBe(1); | |
| 3289 | 3295 | |
| 3290 | 3296 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); |
| 3291 | 3297 | }); |
| @@ -3349,6 +3355,150 @@ describe("the app's page tails the deploy that is bringing it alive", () => { | ||
| 3349 | 3355 | }); |
| 3350 | 3356 | }); |
| 3351 | 3357 | |
| 3358 | /** | |
| 3359 | * Issue #163: "Now" is built from agent runs alone, so a fresh app with a | |
| 3360 | * deploy going and nothing else read as if nothing were happening — right | |
| 3361 | * beside the head card tailing that very deploy. "Now"'s empty state now | |
| 3362 | * says otherwise when the head card is tailing a deploy, and keeps its | |
| 3363 | * ordinary wording when nothing at all is going. | |
| 3364 | */ | |
| 3365 | describe(`"Now" while production is coming up and nothing else is`, () => { | |
| 3366 | let dataDir: string; | |
| 3367 | let app: App; | |
| 3368 | let servers: Servers; | |
| 3369 | let base: string; | |
| 3370 | let runtime: AppsRuntime; | |
| 3371 | let repo: Repo; | |
| 3372 | let record: AppRecord; | |
| 3373 | let owner: string; | |
| 3374 | ||
| 3375 | interface PreviewSlot { | |
| 3376 | readonly app: AppRecord; | |
| 3377 | readonly env: EnvName; | |
| 3378 | readonly chain: Promise<void>; | |
| 3379 | readonly restarts: number[]; | |
| 3380 | deploying?: unknown; | |
| 3381 | live?: unknown; | |
| 3382 | lastError?: string; | |
| 3383 | lastRunId?: string; | |
| 3384 | } | |
| 3385 | const putEnv = ( | |
| 3386 | env: EnvName, | |
| 3387 | slot: Omit<PreviewSlot, "app" | "env" | "chain" | "restarts">, | |
| 3388 | ): void => { | |
| 3389 | (runtime as unknown as { envs: Map<string, PreviewSlot> }).envs.set( | |
| 3390 | `${record.id}/${env}`, | |
| 3391 | { app: record, env, chain: Promise.resolve(), restarts: [], ...slot }, | |
| 3392 | ); | |
| 3393 | }; | |
| 3394 | ||
| 3395 | const signIn = async (username: string): Promise<string> => | |
| 3396 | ( | |
| 3397 | ( | |
| 3398 | await fetch(`${base}/signin`, { | |
| 3399 | method: "POST", | |
| 3400 | body: new URLSearchParams({ | |
| 3401 | username, | |
| 3402 | password: "a-good-passphrase", | |
| 3403 | }), | |
| 3404 | redirect: "manual", | |
| 3405 | }) | |
| 3406 | ).headers.get("set-cookie") ?? "" | |
| 3407 | ).split(";")[0]!; | |
| 3408 | ||
| 3409 | const get = (path: string, cookie: string) => | |
| 3410 | fetch(`${base}${path}`, { | |
| 3411 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 3412 | redirect: "manual", | |
| 3413 | }); | |
| 3414 | ||
| 3415 | beforeAll(async () => { | |
| 3416 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-now-empty-")); | |
| 3417 | app = createApp({ dataDir, port: 0 }); | |
| 3418 | servers = await serve(app); | |
| 3419 | base = servers.url; | |
| 3420 | runtime = new AppsRuntime(app, { | |
| 3421 | domain: "nowempty.test", | |
| 3422 | scheme: "http", | |
| 3423 | publicPort: servers.port, | |
| 3424 | forgeUrl: servers.url, | |
| 3425 | concurrency: 1, | |
| 3426 | }); | |
| 3427 | const a = await createUser(app.db, { | |
| 3428 | username: "alice", | |
| 3429 | email: "[email protected]", | |
| 3430 | password: "a-good-passphrase", | |
| 3431 | adminIfFirst: true, | |
| 3432 | }); | |
| 3433 | if (!a.ok) throw new Error(a.error); | |
| 3434 | owner = await signIn("alice"); | |
| 3435 | const made = await createRepo(app, a.value, { | |
| 3436 | name: "ember", | |
| 3437 | visibility: "private", | |
| 3438 | description: "a new app", | |
| 3439 | }); | |
| 3440 | if (!made.ok) throw new Error(String(made.error)); | |
| 3441 | repo = made.value; | |
| 3442 | const seeded = await commitToBranch(app, a.value, repo, { | |
| 3443 | message: "Start", | |
| 3444 | files: [{ path: "README.md", content: "ember\n" }], | |
| 3445 | }); | |
| 3446 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 3447 | const enabled = await enableApp(app, a.value, repo, { slug: "ember" }); | |
| 3448 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 3449 | record = getAppByRepo(app.db, repo.id)!; | |
| 3450 | }); | |
| 3451 | ||
| 3452 | afterAll(async () => { | |
| 3453 | servers.stop(); | |
| 3454 | app.close(); | |
| 3455 | await rm(dataDir, { recursive: true, force: true }); | |
| 3456 | }); | |
| 3457 | ||
| 3458 | test("a running deploy and no agent run: \"Now\" does not say nothing is happening, and opens no second tail", async () => { | |
| 3459 | const deployRun = createRun(app.db, { | |
| 3460 | repoId: repo.id, | |
| 3461 | kind: "deploy", | |
| 3462 | env: "production", | |
| 3463 | commitOid: "eae580a", | |
| 3464 | reason: "boot", | |
| 3465 | }); | |
| 3466 | claimRun(app.db, deployRun.id); | |
| 3467 | appendRunLog(app.config.dataDir, deployRun.id, "deploy in progress"); | |
| 3468 | putEnv("production", { | |
| 3469 | deploying: { | |
| 3470 | run: { id: deployRun.id, commitOid: "eae580a" }, | |
| 3471 | abort: new AbortController(), | |
| 3472 | }, | |
| 3473 | }); | |
| 3474 | ||
| 3475 | const page = await (await get("/alice/ember", owner)).text(); | |
| 3476 | expect(page).not.toContain("Nothing yet"); | |
| 3477 | expect(page).toContain("Production is coming up"); | |
| 3478 | // Exactly one tail on the page — the head card's, not a second one in | |
| 3479 | // "Now". | |
| 3480 | expect(page.match(/id="app-run-tail"/g)?.length).toBe(1); | |
| 3481 | ||
| 3482 | finishRun(app.db, deployRun.id, { state: "ok", summary: "deploy" }); | |
| 3483 | }); | |
| 3484 | ||
| 3485 | test("with nothing going at all, \"Now\" reads exactly as it does today", async () => { | |
| 3486 | putEnv("production", { | |
| 3487 | live: { | |
| 3488 | commitOid: "eae580a", | |
| 3489 | handle: { port: 4502 }, | |
| 3490 | runId: "irrelevant", | |
| 3491 | since: new Date().toISOString(), | |
| 3492 | manifest: { tty: null }, | |
| 3493 | }, | |
| 3494 | }); | |
| 3495 | ||
| 3496 | const page = await (await get("/alice/ember", owner)).text(); | |
| 3497 | expect(page).toContain("Nothing yet. Tell the crew something."); | |
| 3498 | expect(page).not.toContain('id="app-run-tail"'); | |
| 3499 | }); | |
| 3500 | }); | |
| 3501 | ||
| 3352 | 3502 | /** |
| 3353 | 3503 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 3354 | 3504 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline