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

Merged opened this in answer to

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

Merged

torque/forge:torque/11007-displays-2-3-s-to-list-two-rows-and-no-w → main 7 commits fork 12 threads · 12 unresolved

  • Merged. torque/11007-displays-2-3-s-to-list-two-rows-and-no-w was merged into main as 5f17a7f.

Review

Sign in to review this change.
7 files changed +295 −18
docs/features/displays.md +6 −4
@@ -2,9 +2,11 @@
22
33 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.
44
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.
outdated

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. Add DISPLAYS_SENTENCE to 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.

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.
77
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.
99
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> {
4141 this.epoch.set(key, (this.epoch.get(key) ?? 0) + 1);
4242 }
4343
44 has(key: string): boolean {
45 return this.kept.has(key);
46 }
47
4448 keys(): IterableIterator<string> {
4549 return this.kept.keys();
4650 }
@@ -246,13 +250,73 @@ export async function listedDisplays(app: App, viewer: User | undefined): Promis
246250 return shown.map((d, i) => ({ ...d, demos: counts[i] }));
247251 }
248252
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 }> {

listedDisplaysWithin leaves the doc comment of DISPLAYS_NOTE attached to the new DISPLAYS_SENTENCE (see the lower hunk): the old comment now documents the wrong constant. Also warmDisplays runs listedDisplays(app, undefined) and then reads every repo again; one pass over listEveryRepo is enough. Say what refreshes the cache after a push, or the page goes cold again and shows the reload notice.

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
249309 /** Of `repos`, those with no `display.json` on their default branch. */
250310 export async function withoutDisplay(app: App, repos: readonly Repo[]): Promise<Repo[]> {
251311 const read = await inTurn(repos, (repo) => displayOf(app, repo));
252312 return repos.filter((_, i) => read[i] === undefined);
253313 }
254314
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.";
257319 /** The link text for a display whose file does not parse. */
258320 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";
1818 import { releaseEnv } from "./release/env.ts";
1919 import { createApp } from "./app.ts";
2020 import { readOnlyFrom } from "./http/read_only.ts";
21 import { warmDisplays } from "./display/known.ts";
2122 import { warmFlow } from "./system/flow_cache.ts";
2223 import { backfillCarried } from "./core/carried.ts";
2324 import { refreshCatalogQuietly } from "./packs/catalog.ts";
@@ -564,6 +565,8 @@ if (crew !== undefined) {
564565 void bootHostedChildren(app);
565566 // The pipeline answer is filled in slices behind the listener, so no read computes it (#8978).
566567 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);
567570 // A spawned child's first boot: its direction, opened once for its planner
568571 // (spawn/first-boot.ts). A forge that was not spawned is untouched.
569572 await firstBoot(app);
src/tui/screens/displays.ts +37 −7
@@ -1,14 +1,16 @@
11 /**
22 * Displays (/displays on the web): every display the viewer may read, one
33 * 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.
57 */
68
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";
810 import type { Ctx, Screen } from "../app/app.ts";
911 import { binding } from "../app/keys.ts";
1012 import { style } from "../term/style.ts";
11 import { truncate } from "../term/width.ts";
13 import { truncate, wrap } from "../term/width.ts";
1214 import type { Session } from "../session.ts";
1315 import { fill, len, split } from "../ui/layout.ts";
1416 import { ListState, drawList, type ListRow } from "./common.ts";
@@ -22,19 +24,47 @@ export function displayRow(d: ListedDisplay): ListRow {
2224 return { title: d.config.value.title, secondary: `${d.config.value.layout} · ${name} · ${demos} ${demos === 1 ? "demo" : "demos"}` };
2325 }
2426
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 {
2632 const state = new ListState();
2733 let shown: ListedDisplay[] | undefined;
34 let reading = false;
2835 return {
2936 title: "Displays",
3037 keys: [binding(["j", "k"], "move cursor")],
outdated

The sentence is one len(1) row truncated to the width; at 80 columns it loses its end. Give it two rows or shorten it.

3138 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);
3351 },
3452 render(area, s) {
3553 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`;
3868 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
3969 drawList(s, list!, c, state, (shown ?? []).map(displayRow));
4070 },
src/web/pages/displays.ts +10 −4
@@ -1,13 +1,13 @@
11 /**
22 * `/displays`: every display the viewer may read — each repository with a
33 * `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.
55 * Read through the display cache (`display/known.ts`), so a warm page reads
66 * no git. Read only: making a display is not here.
77 */
88
99 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";
1111 import type { Ctx } from "../../http/context.ts";
1212 import { pageHead, repoBase, signInFirst } from "../chrome.ts";
1313 import { html } from "../html.ts";
@@ -16,17 +16,23 @@ import { empty } from "../ui.ts";
1616
1717 const LAYOUT_WORD = { shelf: "Shelf", grid: "Grid", graph: "Graph" } as const;
1818
19 export { LIST_WAIT_MS, listWait };
20
1921 // GET /displays — every display the viewer may read; a stranger signs in first.
2022 export async function displaysPage(ctx: Ctx): Promise<Response> {
2123 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2224 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));
2426 return timePhase(ctx.request, "render", () =>
2527 page({
2628 ctx,
2729 title: "Displays",
2830 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 complete first. Also make the notice actionable without JS (a link or a refresh), not 'reload in a moment'.

3036 ? empty({ icon: "pin", title: "No displays", note: DISPLAYS_NOTE })
3137 : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Displays">
3238 <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: took < 200 is a wall-clock bound, which the direction forbids ("fast tests that never flake"). It also passes without the change, since the 12 fixture repos are warm quickly. Count git reads instead, as display-warm-reads.test.ts does.

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 rows is [] and complete is never asserted. Build a cold app with repos and a display, call with a tiny ms, and assert complete === false, that the page shows the "Still reading" notice, and that the later call returns the full row. LIST_WAIT_MS <= 150 just restates a constant. The cold app is also never stopped and its temp dir never removed.

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";
1919 import { repoDir } from "../src/git/repos.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
2121 import { createForgeApp } from "../src/tui/forge.ts";
22 import { DISPLAYS_SENTENCE } from "../src/display/known.ts";
2223 import { entry as displaysEntry, displaysScreen } from "../src/tui/screens/displays.ts";
2324 import { ENTRIES } from "../src/tui/screens/entries.ts";
2425 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -129,6 +130,7 @@ describe("the displays list, page and terminal screen", () => {
129130 const mine = await text("/displays", alice);
130131 expect(listed(mine)).toEqual({ "alice/arcade": "3", "alice/shelf": "2", "alice/hidden": "2", "alice/broken": "None" });
131132 expect(mine).toContain('href="/alice/arcade"');
133 expect(mine).toContain(DISPLAYS_SENTENCE.slice(0, 60));
132134 expect(mine).toContain("Arcade night");
133135 expect(mine).toContain("display.json is invalid");
134136 // 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", () => {
166168 const r = await fetch(`${bareServers.url}/displays`, { headers: { cookie: `forge_session=${createSession(bare.db, made.value.id).token}` } });
167169 const page = await r.text();
168170 expect(page).toContain("No displays");
169 expect(page).toContain("A display shows a repository&#39;s showcased demos.");
171 expect(page).toContain("A display is a showcase screen");
170172 expect(page).toContain("Add a display.json to a repository&#39;s default branch");
171173 } finally {
172174 bareServers.stop();
@@ -191,6 +193,7 @@ describe("the displays list, page and terminal screen", () => {
191193 };
192194 const mine = await screenText(alice);
193195 for (const name of Object.keys(listed(await text("/displays", alice)))) expect(mine).toContain(name);
196 expect(mine).toContain(DISPLAYS_SENTENCE.slice(0, 60));
194197 expect(mine).toContain("Arcade night");
195198 expect(mine).toContain("display.json is invalid");
196199 expect(mine).toContain("alice/hidden");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs