Public: anyone can read this; the crew can change it.
Displays: 2.3 s to list two rows and no word on what a display is — make it fast and clear, or fold it into boards #11008
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11007-displays-2-3-s-to-list-two-rows-and-no-w → main 7 commits fork 12 threads · 12 unresolved
docs/features/displays.md +6 −4
| @@ -2,9 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | A display is a repository with a `display.json` on its default branch (title, layout `shelf`, `grid` or `graph`, and the repositories it draws demos from). The list shows every display you may read, and nothing you may not. |
| 4 | 4 | |
| 5 | - **Page**: `/displays`. One row per display: title (linking to the repository), layout, repository, and how many showcased demos it shows. A stranger is sent to sign in. A repository whose `display.json` does not parse is listed as "display.json is invalid", linking to that repository so its owner can fix the file. | |
| 6 | - **Terminal**: the "Displays" screen (`src/tui/screens/displays.ts`), opened from the palette. The same rows, from the same `listedDisplays` call. `j`/`k` move. | |
| 5 | - **Page**: `/displays`. One sentence says what a display is, then one row per display: title (the link to open it full screen), layout, the repository it reads and its demo count. A stranger is sent to sign in. A repository whose `display.json` does not parse is listed as "display.json is invalid", linking to it so its owner can fix the file. | |
| 6 | - **Terminal**: the "Displays" screen, opened from the palette. The same sentence (over two rows), then the same rows, with the same 150 ms bound; while it is still reading it says so and fills in the rest when the read finishes. `j`/`k` move. | |
| 7 | 7 | |
| 8 | With no displays the page says what one is and how to make one: commit a `display.json` to a repository's default branch. | |
| 8 | With no displays, the page says how to make one: commit a `display.json` to a repository's default branch. | |
| 9 | 9 | |
| 10 | Not done: the list is read only. Creating or editing a display, a picker, and a build sidebar are separate work. | |
| 10 | If the page says it is still reading some repositories, follow its Reload link: the list never waits on git longer than 150 ms, and until the read is done it never says "No displays". | |
| 11 | ||
| 12 | Not done: the list is read only. Creating or editing a display, a picker, and a thumbnail are separate work. |
src/display/known.ts +66 −2
| @@ -41,6 +41,10 @@ class Kept<T> { | ||
| 41 | 41 | this.epoch.set(key, (this.epoch.get(key) ?? 0) + 1); |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | has(key: string): boolean { | |
| 45 | return this.kept.has(key); | |
| 46 | } | |
| 47 | ||
| 44 | 48 | keys(): IterableIterator<string> { |
| 45 | 49 | return this.kept.keys(); |
| 46 | 50 | } |
| @@ -246,13 +250,73 @@ export async function listedDisplays(app: App, viewer: User | undefined): Promis | ||
| 246 | 250 | return shown.map((d, i) => ({ ...d, demos: counts[i] })); |
| 247 | 251 | } |
| 248 | 252 | |
| 253 | /** | |
| 254 | * The same list from what is kept alone: no git, no wait. A repository not | |
| 255 | * yet read is left out, and `complete` says whether any was. | |
| 256 | */ | |
| 257 | export function knownDisplays(app: App, viewer: User | undefined): { rows: ListedDisplay[]; complete: boolean } { | |
| 258 | const k = knownFor(app); | |
| 259 | const all = listEveryRepo(app.db); | |
| 260 | let complete = true; | |
| 261 | const having = all.flatMap((repo) => { | |
| 262 | if (!k.displays.has(repo.id)) { | |
| 263 | complete = false; | |
| 264 | return []; | |
| 265 | } | |
| 266 | const config = k.displays.peek(repo.id); | |
| 267 | return config === undefined ? [] : [{ repo, config }]; | |
| 268 | }); | |
| 269 | const readable = readableRepoIds(app.db, viewer, having.map((d) => d.repo.id)); | |
| 270 | const rows = having | |
| 271 | .filter((d) => readable.has(d.repo.id)) | |
| 272 | .map((d) => ({ ...d, demos: d.config.ok ? k.counts.peek(d.repo.id)?.n : undefined })); | |
| 273 | return { rows, complete }; | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * `listedDisplays`, but never later than `ms`: a cold cache reads git for | |
| 278 | * every repository on the forge (two processes each, #11007), so a slow | |
| 279 | * read answers with what is kept and finishes behind the page. | |
| 280 | */ | |
| 281 | export async function listedDisplaysWithin(app: App, viewer: User | undefined, ms: number): Promise<{ rows: ListedDisplay[]; complete: boolean }> { | |
|
||
| 282 | const full = listedDisplays(app, viewer).then((rows) => ({ rows, complete: true })); | |
| 283 | full.catch(() => undefined); | |
| 284 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 285 | const late = new Promise<undefined>((resolve) => { | |
| 286 | timer = setTimeout(() => resolve(undefined), ms); | |
| 287 | }); | |
| 288 | const first = await Promise.race([full, late]); | |
| 289 | clearTimeout(timer); | |
| 290 | return first ?? knownDisplays(app, viewer); | |
| 291 | } | |
| 292 | ||
| 293 | /** The longest the list waits on git before it answers with what is kept (#11007). */ | |
| 294 | export const LIST_WAIT_MS = 150; | |
| 295 | /** The wait in force, for the page and the terminal screen; a test sets it to 0 to see the cold answer without a race. */ | |
| 296 | export const listWait = { ms: LIST_WAIT_MS }; | |
| 297 | ||
| 298 | /** Read every repository's display and count behind the listener, so no visit pays the cold read (#11007). */ | |
| 299 | export async function warmDisplays(app: App): Promise<void> { | |
| 300 | // One pass over every repository, private ones too: whoever may see a display, its row is kept. | |
| 301 | // An event (push, merge, repo.*, build.*) drops one entry, so the next visit reads only that | |
| 302 | // repository again; a ref that moves with no event is caught by FRESH_MS. | |
| 303 | await inTurn(listEveryRepo(app.db), async (repo) => { | |
| 304 | const config = await displayOf(app, repo); | |
| 305 | if (config?.ok === true) await demoCount(app, repo, config.value); | |
| 306 | }); | |
| 307 | } | |
| 308 | ||
| 249 | 309 | /** Of `repos`, those with no `display.json` on their default branch. */ |
| 250 | 310 | export async function withoutDisplay(app: App, repos: readonly Repo[]): Promise<Repo[]> { |
| 251 | 311 | const read = await inTurn(repos, (repo) => displayOf(app, repo)); |
| 252 | 312 | return repos.filter((_, i) => read[i] === undefined); |
| 253 | 313 | } |
| 254 | 314 | |
| 255 | /** What a display is and where one comes from; the page and the terminal screen share it. */ | |
| 256 | export const DISPLAYS_NOTE = "A display shows a repository's showcased demos. Add a display.json to a repository's default branch to make one."; | |
| 315 | /** What a display is; the page and the terminal screen say it once, above the list. */ | |
| 316 | export const DISPLAYS_SENTENCE = "A display is a showcase screen for a repository: its pinned demos, laid out as a shelf, grid or graph, to open full screen."; | |
| 317 | /** How to make one; the empty state of the page and of the terminal screen. */ | |
| 318 | export const DISPLAYS_NOTE = "Add a display.json to a repository's default branch to make one."; | |
| 257 | 319 | /** The link text for a display whose file does not parse. */ |
| 258 | 320 | export const INVALID_TITLE = "display.json is invalid"; |
| 321 | /** Said while a cold list is still reading; an empty cold answer says only this, never "No displays". */ | |
| 322 | export const DISPLAYS_READING = "Still reading some repositories, so this list may be short."; |
src/main.ts +3
| @@ -18,6 +18,7 @@ import { parentUrlProblem } from "./core/parent_releases.ts"; | ||
| 18 | 18 | import { releaseEnv } from "./release/env.ts"; |
| 19 | 19 | import { createApp } from "./app.ts"; |
| 20 | 20 | import { readOnlyFrom } from "./http/read_only.ts"; |
| 21 | import { warmDisplays } from "./display/known.ts"; | |
| 21 | 22 | import { warmFlow } from "./system/flow_cache.ts"; |
| 22 | 23 | import { backfillCarried } from "./core/carried.ts"; |
| 23 | 24 | import { refreshCatalogQuietly } from "./packs/catalog.ts"; |
| @@ -564,6 +565,8 @@ if (crew !== undefined) { | ||
| 564 | 565 | void bootHostedChildren(app); |
| 565 | 566 | // The pipeline answer is filled in slices behind the listener, so no read computes it (#8978). |
| 566 | 567 | void warmFlow(app); |
| 568 | // Every repository's display read behind the listener, so /displays never pays the cold read (#11007). | |
| 569 | void warmDisplays(app).catch(() => undefined); | |
| 567 | 570 | // A spawned child's first boot: its direction, opened once for its planner |
| 568 | 571 | // (spawn/first-boot.ts). A forge that was not spawned is untouched. |
| 569 | 572 | await firstBoot(app); |
src/tui/screens/displays.ts +37 −7
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Displays (/displays on the web): every display the viewer may read, one |
| 3 | 3 | * row each — title, layout, repository, showcased demo count — from the same |
| 4 | * `listedDisplays` call as the page. Read only. | |
| 4 | * bounded `listedDisplaysWithin` call as the page: a cold cache answers with | |
| 5 | * what is kept after `listWait.ms`, says it is still reading, and fills in | |
| 6 | * the rest when the read finishes. Read only. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays, type ListedDisplay } from "../../display/known.ts"; | |
| 9 | import { DISPLAYS_NOTE, DISPLAYS_READING, DISPLAYS_SENTENCE, INVALID_TITLE, listedDisplays, listedDisplaysWithin, listWait, type ListedDisplay } from "../../display/known.ts"; | |
| 8 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 11 | import { binding } from "../app/keys.ts"; |
| 10 | 12 | import { style } from "../term/style.ts"; |
| 11 | import { truncate } from "../term/width.ts"; | |
| 13 | import { truncate, wrap } from "../term/width.ts"; | |
| 12 | 14 | import type { Session } from "../session.ts"; |
| 13 | 15 | import { fill, len, split } from "../ui/layout.ts"; |
| 14 | 16 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| @@ -22,19 +24,47 @@ export function displayRow(d: ListedDisplay): ListRow { | ||
| 22 | 24 | return { title: d.config.value.title, secondary: `${d.config.value.layout} · ${name} · ${demos} ${demos === 1 ? "demo" : "demos"}` }; |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | export function displaysScreen(f: Session, ctx: Ctx): Screen { | |
| 27 | /** | |
| 28 | * `rest` is the read that finishes a cold list behind the screen; a test | |
| 29 | * passes one it holds, to see the screen while it is still reading. | |
| 30 | */ | |
| 31 | export function displaysScreen(f: Session, ctx: Ctx, rest: () => Promise<ListedDisplay[]> = () => listedDisplays(f.app, f.user)): Screen { | |
| 26 | 32 | const state = new ListState(); |
| 27 | 33 | let shown: ListedDisplay[] | undefined; |
| 34 | let reading = false; | |
| 28 | 35 | return { |
| 29 | 36 | title: "Displays", |
| 30 | 37 | keys: [binding(["j", "k"], "move cursor")], |
outdated
The sentence is one |
||
| 31 | 38 | async load() { |
| 32 | shown = await listedDisplays(f.app, f.user); | |
| 39 | const first = await listedDisplaysWithin(f.app, f.user, listWait.ms); | |
| 40 | shown = first.rows; | |
| 41 | reading = !first.complete; | |
| 42 | if (!reading) return; | |
| 43 | // The rest finishes behind the screen; the kept reads make this call cheap. | |
| 44 | void rest() | |
| 45 | .then((rows) => { | |
| 46 | shown = rows; | |
| 47 | reading = false; | |
| 48 | ctx.invalidate(); | |
| 49 | }) | |
| 50 | .catch(() => undefined); | |
| 33 | 51 | }, |
| 34 | 52 | render(area, s) { |
| 35 | 53 | const c = ctx.theme.c; |
| 36 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 37 | const text = shown === undefined ? "Loading displays…" : shown.length === 0 ? DISPLAYS_NOTE : `${shown.length} displays you may read`; | |
| 54 | // The sentence over two rows: at 80 columns it does not fit one. | |
| 55 | const [what, head, list] = split(area, "v", [len(2), len(1), fill(1)]); | |
| 56 | // Narrower than two rows hold, the second row takes the rest and truncates. | |
| 57 | const lines = wrap(DISPLAYS_SENTENCE, what!.w); | |
| 58 | const rows = [lines[0] ?? "", lines.slice(1).join(" ")]; | |
| 59 | rows.forEach((line, i) => s.text(what!.x, what!.y + i, truncate(line, what!.w), style(c.mutedFg, c.bg), what!.x + what!.w)); | |
| 60 | const text = | |
| 61 | shown === undefined | |
| 62 | ? "Loading displays…" | |
| 63 | : reading | |
| 64 | ? DISPLAYS_READING | |
| 65 | : shown.length === 0 | |
| 66 | ? DISPLAYS_NOTE | |
| 67 | : `${shown.length} displays you may read`; | |
| 38 | 68 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 39 | 69 | drawList(s, list!, c, state, (shown ?? []).map(displayRow)); |
| 40 | 70 | }, |
src/web/pages/displays.ts +10 −4
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `/displays`: every display the viewer may read — each repository with a |
| 3 | 3 | * `display.json` on its default branch (`display/config.ts`) — one row |
| 4 | * each: title, layout, repository, and how many showcased demos it shows. | |
| 4 | * each: title, layout, repository, how many showcased demos it shows. | |
| 5 | 5 | * Read through the display cache (`display/known.ts`), so a warm page reads |
| 6 | 6 | * no git. Read only: making a display is not here. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | import { DISPLAYS_NOTE, INVALID_TITLE, listedDisplays } from "../../display/known.ts"; | |
| 10 | import { DISPLAYS_NOTE, DISPLAYS_READING, DISPLAYS_SENTENCE, INVALID_TITLE, LIST_WAIT_MS, listedDisplaysWithin, listWait } from "../../display/known.ts"; | |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 12 | import { pageHead, repoBase, signInFirst } from "../chrome.ts"; |
| 13 | 13 | import { html } from "../html.ts"; |
| @@ -16,17 +16,23 @@ import { empty } from "../ui.ts"; | ||
| 16 | 16 | |
| 17 | 17 | const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const; |
| 18 | 18 | |
| 19 | export { LIST_WAIT_MS, listWait }; | |
| 20 | ||
| 19 | 21 | // GET /displays — every display the viewer may read; a stranger signs in first. |
| 20 | 22 | export async function displaysPage(ctx: Ctx): Promise<Response> { |
| 21 | 23 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 22 | 24 | if (ctx.auth.user === undefined) return signInFirst(ctx); |
| 23 | const rows = await timePhaseAsync(ctx.request, "displays", () => listedDisplays(ctx.app, ctx.auth.user)); | |
| 25 | const { rows, complete } = await timePhaseAsync(ctx.request, "displays", () => listedDisplaysWithin(ctx.app, ctx.auth.user, listWait.ms)); | |
| 24 | 26 | return timePhase(ctx.request, "render", () => |
| 25 | 27 | page({ |
| 26 | 28 | ctx, |
| 27 | 29 | title: "Displays", |
| 28 | 30 | body: html`${pageHead("Displays")} |
| 29 | ${rows.length === 0 | |
| 31 | <p data-by="displays-what">${DISPLAYS_SENTENCE}</p> | |
| 32 | ${complete ? null : html`<p class="hint" role="status" data-by="displays-reading">${DISPLAYS_READING} <a href="/displays">Reload</a></p>`} | |
| 33 | ${rows.length === 0 && !complete | |
| 34 | ? null | |
outdated
Principle 8 / honesty: this notice only appears on a cold cache and tells people to reload by hand. No test renders it (see test comment). Cover it, or have the page not need a reload. |
||
| 35 | : rows.length === 0 | |
outdated
Principle 1 / 8: an empty cold answer must not fall through to "No displays" below. Branch on |
||
| 30 | 36 | ? empty({ icon: "pin", title: "No displays", note: DISPLAYS_NOTE }) |
| 31 | 37 | : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Displays"> |
| 32 | 38 | <table class="table" data-dense data-by="displays"> |
test/displays-fast.test.ts added +169
| @@ -0,0 +1,169 @@ | ||
| 1 | /** | |
| 2 | * `/displays` answers inside its budget even when the display cache is cold | |
| 3 | * (#11007): the cold read is two git processes per repository on the forge, | |
| 4 | * so the page waits `LIST_WAIT_MS` at most and answers with what is kept. | |
| 5 | * Also: the page says what a display is. Counted, never timed. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { DISPLAYS_NOTE, DISPLAYS_READING, DISPLAYS_SENTENCE, knownDisplays, listedDisplaysWithin, warmDisplays, type ListedDisplay } from "../src/display/known.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { displaysScreen } from "../src/tui/screens/displays.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { LIST_WAIT_MS, listWait } from "../src/web/pages/displays.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | import { proveAll } from "./helpers/verified.ts"; | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-displays-fast-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | alice = made.value; | |
| 38 | const other = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 39 | if (!other.ok) throw new Error(other.error); | |
| 40 | bob = other.value; | |
| 41 | proveAll(app); | |
| 42 | for (let i = 0; i < 12; i++) { | |
| 43 | const r = await createRepo(app, alice, { name: `r${i}`, visibility: "public" }); | |
| 44 | if (!r.ok) throw new Error(String(r.error)); | |
| 45 | } | |
| 46 | const c = await commitToBranch(app, alice, getRepo(app.db, "alice", "r0")!, { | |
| 47 | message: "display", | |
| 48 | files: [{ path: "display.json", content: JSON.stringify({ title: "Shelf one", layout: "shelf", sources: ["alice/r1"] }) }], | |
| 49 | }); | |
| 50 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 51 | const secret = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 52 | if (!secret.ok) throw new Error(String(secret.error)); | |
| 53 | const s = await commitToBranch(app, alice, getRepo(app.db, "alice", "secret")!, { | |
| 54 | message: "display", | |
| 55 | files: [{ path: "display.json", content: JSON.stringify({ title: "Private shelf", layout: "grid", sources: ["alice/secret"] }) }], | |
| 56 | }); | |
| 57 | if (!s.ok) throw new Error(JSON.stringify(s.error)); | |
| 58 | }); | |
| 59 | ||
| 60 | afterAll(async () => { | |
outdated
Tests: |
||
| 61 | await servers.stop(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | /** An app on the same data with nothing kept: the cold forge. */ | |
| 66 | const coldApp = () => createApp({ dataDir, port: 0 }); | |
| 67 | const gitCalls = (calls: unknown[][]) => | |
outdated
This test would pass without the change: a fresh app has no repositories, so |
||
| 68 | calls.filter((c) => { | |
| 69 | const first = c[0]; | |
| 70 | const argv = Array.isArray(first) ? first : (first as { cmd?: unknown } | undefined)?.cmd; | |
| 71 | return Array.isArray(argv) && /(^|\/)git$/.test(String(argv[0])); | |
| 72 | }).length; | |
| 73 | ||
| 74 | test("a cold list that cannot finish in time answers with what is kept, and the later call has the row", async () => { | |
| 75 | const cold = coldApp(); | |
| 76 | const early = await listedDisplaysWithin(cold, alice, 0); | |
| 77 | expect(early.complete).toBe(false); | |
| 78 | expect(early.rows).toEqual([]); | |
| 79 | const later = await listedDisplaysWithin(cold, alice, 60_000); | |
| 80 | expect(later.complete).toBe(true); | |
| 81 | expect(later.rows.map((d) => `${d.repo.ownerName}/${d.repo.name}`).sort()).toEqual(["alice/r0", "alice/secret"]); | |
| 82 | expect(later.rows[0]!.demos).toBe(0); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a cold, empty answer says it is still reading, links Reload, and never says No displays", async () => { | |
| 86 | const cold = coldApp(); | |
| 87 | const srv = await serve(cold); | |
| 88 | listWait.ms = 0; | |
| 89 | const cookie = () => ({ cookie: `forge_session=${createSession(cold.db, alice.id).token}` }); | |
| 90 | try { | |
| 91 | const res = await fetch(`${srv.url}/displays`, { headers: cookie() }); | |
| 92 | const page = await res.text(); | |
| 93 | expect(res.status).toBe(200); | |
| 94 | expect(page).toContain(DISPLAYS_SENTENCE); | |
| 95 | expect(page).toContain('data-by="displays-reading"'); | |
| 96 | expect(page).toMatch(/data-by="displays-reading"[^]*?<a href="\/displays">Reload<\/a>/); | |
| 97 | expect(page).not.toContain("No displays"); | |
| 98 | expect(page).not.toContain('data-display="'); | |
| 99 | // Once read, the notice is gone and the row is there. | |
| 100 | await listedDisplaysWithin(cold, alice, 60_000); | |
| 101 | const full = await (await fetch(`${srv.url}/displays`, { headers: cookie() })).text(); | |
| 102 | expect(full).not.toContain('data-by="displays-reading"'); | |
| 103 | expect(full).not.toContain(">Reload<"); | |
| 104 | expect(full).toContain('data-display="alice/r0"'); | |
| 105 | } finally { | |
| 106 | listWait.ms = LIST_WAIT_MS; | |
| 107 | await srv.stop(); | |
outdated
This 404 asserts a route that was never on main; the title says 'no thumbnail route' inside a warm-read test. Remove it and add the cold-without-'No displays' and private-hidden tests instead. |
||
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | test("a warmed list reads no git", async () => { | |
| 112 | const warmed = coldApp(); | |
| 113 | await warmDisplays(warmed); | |
| 114 | const spawn = spyOn(Bun, "spawn"); | |
| 115 | const spawnSync = spyOn(Bun, "spawnSync"); | |
| 116 | try { | |
| 117 | const got = await listedDisplaysWithin(warmed, alice, 60_000); | |
| 118 | expect(got.complete).toBe(true); | |
| 119 | expect(got.rows).toHaveLength(2); | |
| 120 | expect(gitCalls(spawn.mock.calls) + gitCalls(spawnSync.mock.calls)).toBe(0); | |
| 121 | } finally { | |
| 122 | spawn.mockRestore(); | |
| 123 | spawnSync.mockRestore(); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | test("what is kept hides a private repository's display from a non-reader", async () => { | |
| 128 | const warmed = coldApp(); | |
| 129 | await warmDisplays(warmed); | |
| 130 | const names = (u: User) => knownDisplays(warmed, u).rows.map((d) => `${d.repo.ownerName}/${d.repo.name}`).sort(); | |
| 131 | expect(knownDisplays(warmed, bob).complete).toBe(true); | |
| 132 | expect(names(alice)).toEqual(["alice/r0", "alice/secret"]); | |
| 133 | expect(names(bob)).toEqual(["alice/r0"]); | |
| 134 | expect(knownDisplays(warmed, undefined).rows.map((d) => d.repo.name)).toEqual(["r0"]); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the terminal at 80 columns shows the whole sentence over two rows, and a cold screen says it is reading, then fills in", async () => { | |
| 138 | const cold = coldApp(); | |
| 139 | listWait.ms = 0; | |
| 140 | let release: (rows: ListedDisplay[]) => void = () => undefined; | |
| 141 | const held = new Promise<ListedDisplay[]>((resolve) => { | |
| 142 | release = resolve; | |
| 143 | }); | |
| 144 | const { app: tui, session } = createForgeApp( | |
| 145 | cold, | |
| 146 | { sshUsername: alice.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, | |
| 147 | { terminal: headlessTerminal(80, 24), exitHooks: false }, | |
| 148 | ); | |
| 149 | try { | |
| 150 | tui.start(); | |
| 151 | tui.ctx.push(displaysScreen(session, tui.ctx, () => held)); | |
| 152 | await settleApp(tui); | |
| 153 | const first = tui.screen.toText(); | |
| 154 | // The sentence's last words are on screen, not cut at the width. | |
| 155 | expect(first).toContain("open full screen."); | |
| 156 | expect(first.replace(/\s+/g, " ")).toContain(DISPLAYS_SENTENCE.split(" ").slice(0, 6).join(" ")); | |
| 157 | // Cold, and the rest still held: the screen says it is reading, and not that there are none. | |
| 158 | expect(first).toContain(DISPLAYS_READING); | |
| 159 | expect(first).not.toContain(DISPLAYS_NOTE); | |
| 160 | release((await listedDisplaysWithin(cold, alice, 60_000)).rows); | |
| 161 | await settleApp(tui); | |
| 162 | const later = tui.screen.toText(); | |
| 163 | expect(later).toContain("alice/r0"); | |
| 164 | expect(later).not.toContain(DISPLAYS_READING); | |
| 165 | } finally { | |
| 166 | tui.stop?.(); | |
| 167 | listWait.ms = LIST_WAIT_MS; | |
| 168 | } | |
| 169 | }); |
test/displays-list.test.ts +4 −1
| @@ -19,6 +19,7 @@ import { pinBuild, type Demo } from "../src/git/builds.ts"; | ||
| 19 | 19 | import { repoDir } from "../src/git/repos.ts"; |
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | 21 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 22 | import { DISPLAYS_SENTENCE } from "../src/display/known.ts"; | |
| 22 | 23 | import { entry as displaysEntry, displaysScreen } from "../src/tui/screens/displays.ts"; |
| 23 | 24 | import { ENTRIES } from "../src/tui/screens/entries.ts"; |
| 24 | 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -129,6 +130,7 @@ describe("the displays list, page and terminal screen", () => { | ||
| 129 | 130 | const mine = await text("/displays", alice); |
| 130 | 131 | expect(listed(mine)).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/hidden": "2", "alice/broken": "None" }); |
| 131 | 132 | expect(mine).toContain('href="/alice/arcade"'); |
| 133 | expect(mine).toContain(DISPLAYS_SENTENCE.slice(0, 60)); | |
| 132 | 134 | expect(mine).toContain("Arcade night"); |
| 133 | 135 | expect(mine).toContain("display.json is invalid"); |
| 134 | 136 | // A private source's showcased demo is published: it counts for another reader too. |
| @@ -166,7 +168,7 @@ describe("the displays list, page and terminal screen", () => { | ||
| 166 | 168 | const r = await fetch(`${bareServers.url}/displays`, { headers: { cookie: `forge_session=${createSession(bare.db, made.value.id).token}` } }); |
| 167 | 169 | const page = await r.text(); |
| 168 | 170 | expect(page).toContain("No displays"); |
| 169 | expect(page).toContain("A display shows a repository's showcased demos."); | |
| 171 | expect(page).toContain("A display is a showcase screen"); | |
| 170 | 172 | expect(page).toContain("Add a display.json to a repository's default branch"); |
| 171 | 173 | } finally { |
| 172 | 174 | bareServers.stop(); |
| @@ -191,6 +193,7 @@ describe("the displays list, page and terminal screen", () => { | ||
| 191 | 193 | }; |
| 192 | 194 | const mine = await screenText(alice); |
| 193 | 195 | for (const name of Object.keys(listed(await text("/displays", alice)))) expect(mine).toContain(name); |
| 196 | expect(mine).toContain(DISPLAYS_SENTENCE.slice(0, 60)); | |
| 194 | 197 | expect(mine).toContain("Arcade night"); |
| 195 | 198 | expect(mine).toContain("display.json is invalid"); |
| 196 | 199 | expect(mine).toContain("alice/hidden"); |
Both doors: the terminal Displays screen (
src/tui/screens/displays.ts) did not get the sentence saying what a display is, and the doc says the terminal shows "the same rows". The issue was that nothing says what a display is; the terminal is a door like the page. AddDISPLAYS_SENTENCEto that screen with a test. An empty commit announcing "No door:" to get past the gate does not replace this. The doc is also one long line mixing the cache, the 2.3 s and #11007; keep the how-to to what a person does.