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

Home opens with a KPI row in studio words, built from the design system's own parts #2878

Merged opened this

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

Merged

scout/forge:scout/2871-home-opens-with-a-kpi-row-in-studio-word → main 8 commits fork 9 threads · 9 unresolved

  • Merged. scout/2871-home-opens-with-a-kpi-row-in-studio-word was merged into main as cdede41.

Review

Sign in to review this change.
11 files changed +386 −2
README.md +9
@@ -254,6 +254,15 @@ settings until asked. `/?all=1` is the whole home, and its "Just my apps" link
254254 comes back. The terminal's first screen opens with the same lines, from the
255255 same query (`src/core/client-apps.ts`).
256256
257 Home shows three figures first, from `src/flow/kpis.ts`: shipped per hour (with
258 a 24 h sparkline), the live version and since when, and the median check time.
259 They count every repository, so only a site admin sees them, on both doors;
260 nobody else does, and neither does the lean client home. On the web each is a
261 card linking to `/board`, `/releases` or `/boards`. The terminal's first screen
262 shows the same three as lines above the forge strip (`kpiLines` in
263 `src/web/studio/kpi_row.ts`). They are read when the page or screen loads and
264 do not refresh on their own.

kpiLines is in src/flow/kpi_words.ts now, not src/web/studio/kpi_row.ts. Fix the path.

265
257266 On a host that cannot hold cells or the crew's shell sandbox, a site admin
258267 sees `Reduced protection: …` in one line, under the filter on the terminal's
259268 first screen and in the web footer; nobody else does, and a fully protected
docs/DESIGN.md +1 −1
@@ -386,7 +386,7 @@ gone, so this list only shrinks.
386386 - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why`
387387 - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap`
388388 - **`pages/milestones.ts`** — milestones and one limb opened: `.ms-actions` `.ms-bar` `.ms-count` `.ms-figures` `.ms-head` `.ms-limb` `.ms-limbs` `.ms-more` `.ms-move` `.ms-name` `.ms-planner` `.ms-rows` `.ms-section` `.ms-state` `.ms-top` `.ms-trim` `.ms-weight`
389 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs`
389 - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs`
390390 - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word`
391391
392392 ---
src/flow/kpi_words.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * The figures row in words (#2871): shared by home and the terminal, which
3 * must not depend on each other. Plain data in, plain strings out.
4 */
5
6 import type { Kpis } from "./kpis.ts";
7
8 /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */
9 export function checkTime(ms: number | null): string {
10 if (ms === null) return "—";
11 const s = Math.round(ms / 1000);
12 if (s < 60) return `${s}s`;
13 const m = Math.floor(s / 60);
14 if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
15 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
16 }
17
18 const BLOCKS = "▁▂▃▄▅▆▇█";
19
20 /** The terminal's sparkline: the same 24 hours, oldest on the left, one block each. */
21 export function sparkText(hours: readonly number[]): string {
22 const top = Math.max(1, ...hours);
23 return [...hours]
24 .reverse()
25 .map((v) => (v === 0 ? BLOCKS[0]! : BLOCKS[Math.min(BLOCKS.length - 1, Math.ceil((v / top) * (BLOCKS.length - 1)))]!))
26 .join("");
27 }
28
29 /**
30 * The terminal's row: the same three figures in the same words, as plain
31 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
32 */
33 export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] {
34 return [
35 `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
36 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
37 `Median check time ${checkTime(k.medianCheckMs)}`,
38 ];
39 }
src/tui/screens/dashboard.ts +17
@@ -69,6 +69,7 @@ import {
6969 ISSUE_MARK,
7070 ListState,
7171 PIN_MARK,
72 ago,
7273 PULL_MARK,
7374 confirmScreen,
7475 countsColumn,
@@ -83,6 +84,10 @@ import {
8384 promptScreen,
8485 plural,
8586 } from "./common.ts";
87 import { readKpis } from "../../flow/kpis.ts";
outdated

The terminal should not import from a web page module. Move kpiLines, sparkText and checkTime to a small module outside web/. The web row and this screen then share it.

88 import { kpiLines } from "../../flow/kpi_words.ts";
89 import { BOOTED } from "../../release/gate.ts";
90 import { running } from "../../kernel/version.ts";
8691 import { deciderLine, deciderScreen } from "./decider.ts";
8792 import { issueScreen } from "./issue.ts";
8893 import { newRepoScreen } from "./newrepo.ts";
@@ -149,6 +154,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
149154 let lane: string | null = null;
150155 /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */
151156 let decider: string | null = null;
157 /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */
158 let figures: string[] = [];
152159 const filter = new Filter();
153160 /** One cursor per pane: moving in one must never move another. */
154161 const cursors: Record<PaneId, ListState> = {
@@ -161,6 +168,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
161168 const loadClientLines = async (): Promise<void> => {
162169 clientLines = [];
163170 if (!f.user || (await nextStepsFor(f.app, f.user)) === undefined) return;
171 // A client's home never carries the forge-wide figures, on either door.
172 figures = [];
164173 const apps = await clientAppRows(f.app, f.user);
165174 clientLines =
166175 apps.length === 0
@@ -203,6 +212,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
203212 }
outdated

Same gate as the web: admin only. Any signed-in user currently gets forge-wide figures on the terminal.

204213 lane = forgeLane(f.app, f.user);
205214 decider = deciderLine(f.app, f.user);
215 figures = f.user?.isAdmin !== true ? [] : kpiLines(readKpis(f.db, now), running().commit === null ? null : { sha: running().commit!, since: ago(BOOTED) });
206216 // "Yours" is ownership plus the orgs you are on a team of; typing in the
207217 // filter reaches every repository you can see.
208218 const orgs = f.user
@@ -763,6 +773,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
763773 );
764774 body = rest!;
765775 }
776 if (figures.length > 0) {
777 const [strip, rest] = split(body, "v", [len(figures.length), fill(1)]);
778 figures.forEach((line, i) =>
779 s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w),
780 );
781 body = rest!;
782 }
766783 if (decider !== null) {
767784 const [strip, rest] = split(body, "v", [len(1), fill(1)]);
768785 s.text(
src/web/studio/feed_style.ts +7
@@ -24,6 +24,13 @@ export const feedCss = /* css */ `
2424 .composer textarea { width: 100%; min-height: 64px; resize: vertical; }
2525 .composer-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
2626 .composer-bar select { min-width: 0; max-width: 60%; }
27 /* The figures row: three cards at a fixed height, so first paint is final. */
outdated

The "Your apps" comment now sits above the figures block instead of above .home-tabs. Put the new block above that comment.

28 .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 12px; }
29 .kpi { display: grid; grid-template-rows: 20px 32px 20px 24px; align-content: start; gap: 2px; padding: 12px 14px; color: inherit; text-decoration: none; }
30 .kpi:hover { border-color: var(--border-strong); }
31 .kpi-value { font-size: 16px; line-height: 32px; font-weight: 600; }
outdated

20px is the page-title size, and 600 is not on the scale for it. Hierarchy should come from weight. Use 16px/600.

32 .kpi-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Honest truncation needs a title with the whole text on the element. Add it in tile().

33 .kpi-spark { display: block; color: var(--muted-fg); max-width: 100%; }
outdated

Accent is interaction only (DESIGN.md rule 2). The sparkline is data, so use var(--muted-fg) or var(--fg).

2734 /* Your apps: a narrow column on a desk, a tab away on a phone. */
2835 .home-tabs { display: none; margin-bottom: 12px; }
2936 .home-app { gap: 8px; }
src/web/studio/home.ts +3 −1
@@ -48,6 +48,7 @@ import { writableAppsOf } from "./asks.ts";
4848 import { needsYouSection } from "./asks_page.ts";
4949 import { flowCards } from "./flow_card.ts";
5050 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
51 import { kpiRow } from "./kpi_row.ts";
5152 import { homeTabs } from "./home_tabs.ts";
5253 import { crewKeyNote } from "./crew.ts";
5354 import { everydayCard } from "../pages/everyday.ts";
@@ -243,8 +244,9 @@ export async function studioHome(
243244 });
244245 }
245246 const ask = firstSteps ?? everydayCard(ctx, user);
247 const figures = user.isAdmin && steps === undefined;
246248 const body = html`<h1 class="sr-only">Home</h1>
outdated

This wiring is untested. Add a home-page test that fails without this line, for admin, non-admin and the lean home.

outdated

This is the only door that gets the row. Put the same three figures on the terminal dashboard (src/tui/screens/dashboard.ts) from readKpis, with a test.

outdated

Gate on user.isAdmin as well as steps === undefined. readKpis is forge-wide, so showing it to every signed-in person leaks private-repo activity.

247 ${ask} ${address} ${liveLine(ctx)}
249 ${ask} ${address} ${figures ? kpiRow(ctx.app.db) : liveLine(ctx)}
248250 ${when(
249251 steps,
250252 () => html`<p class="small muted" id="lean-home">
src/web/studio/kpi_row.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * Home's row of three figures (#2871), from `flow/kpis.ts`: shipped per hour
3 * with a day's sparkline, the live version and since when, and the median
4 * check time. Server-rendered, one fixed-height card each, so nothing moves
5 * when it paints. Only the studio's words: shipped, change, check time.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { checkTime } from "../../flow/kpi_words.ts";
10 import { readKpis, type Kpis } from "../../flow/kpis.ts";
11 import { html, raw, type Html } from "../html.ts";
12 import { BOOTED } from "../../release/gate.ts";
13 import { running } from "../../kernel/version.ts";
14 import { sha, time } from "../ui.ts";
15
16 const W = 96;
17 const H = 24;
18
19 /** The last 24 hours, oldest on the left, as an SVG polyline; the numbers are the only input. */
20 export function sparkline(hours: readonly number[]): Html {
21 const series = [...hours].reverse();
22 const top = Math.max(1, ...series);
23 const step = W / Math.max(1, series.length - 1);
24 const points = series
25 .map((v, i) => `${(i * step).toFixed(1)},${(H - 2 - (v / top) * (H - 4)).toFixed(1)}`)
26 .join(" ");
27 return raw(
28 `<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`,
29 );
30 }
31
32 function tile(label: string, href: string, value: Html | string, note: string | Html, title: string, extra: Html | null = null): Html {
33 return html`<a class="card kpi" href="${href}">
34 <span class="small muted">${label}</span>
35 <span class="kpi-value num">${value}</span>
36 <span class="kpi-note small muted" title="${title}">${note}</span>
37 ${extra}
38 </a>`;
39 }
40
41 /** The version this forge is running, and since when: the same source as `/releases`. */
42 export function liveNow(): { sha: string; at: string } | null {
43 const commit = running().commit;
44 return commit === null ? null : { sha: commit, at: BOOTED };
45 }
46
47 export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Html {
48 const rate = k.landed / k.landedHours.length;
49 return html`<section class="kpis" aria-label="The last 24 hours, as of this page load">
50 ${tile(
51 "Shipped per hour",
52 "/board",
53 rate.toFixed(1),
54 `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
55 `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
56 sparkline(k.landedHours),
57 )}
58 ${tile(
59 "Live version",
60 "/releases",
61 live === null ? "—" : sha(live.sha),
outdated

This sha is the newest production deploy of any app (readKpis().live). The liveLine right above it and /releases, which this tile links to, show the forge's own running().commit. The two disagree on one page. Read from running() and show it once (drop liveLine from the full home), and use .sha, not .mono.

62 live === null ? "Nothing live yet" : html`live ${time(live.at)}`,
63 live === null ? "Nothing live yet" : `Live since ${live.at}`,
64 )}
65 ${tile(
66 "Median check time",
67 "/boards",
68 checkTime(k.medianCheckMs),
69 k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h",
70 k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h",
71 )}
72 </section>`;
73 }
74
75 /** The figures count every repository, so the caller shows this to an admin only. */
76 export function kpiRow(db: Database, now = new Date()): Html {
77 return kpiRowFor(readKpis(db, now), liveNow());
78 }
test/client-home.test.ts +6
@@ -241,6 +241,11 @@ describe("a client's first screen", () => {
241241 expect(all).toContain('href="/"');
242242 });
243243
244 test("a client sees no forge-wide figures, lean or whole", async () => {
245 expect(await page(owner)).not.toContain('class="kpis"');
246 expect(await page(owner, "/?all=1")).not.toContain('class="kpis"');
247 });
248
244249 test("queued agents and no running ones read as about to start", async () => {
245250 const repo = child.db
246251 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'")
@@ -283,6 +288,7 @@ describe("a client's first screen", () => {
283288 app.start();
284289 await settleApp(app);
285290 expect(app.screen.toText()).toContain("bakery: Its agents are waiting for something to do.");
291 expect(app.screen.toText()).not.toContain("Shipped per hour");
286292 app.stop();
287293 });
288294 });
test/home-kpi-page.test.ts added +59
@@ -0,0 +1,59 @@
1 /** The figures row on the real home (#2871): an admin's links, a non-admin's, and where it is not. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { promote } from "./helpers/members.ts";
11
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-home-kpi-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 });
21
22 afterAll(async () => {
23 servers.stop();
24 app.close();
25 await rm(dataDir, { recursive: true, force: true });
26 });
27
28 test("home renders the three tiles; only an admin sees them", async () => {
29 const mk = async (username: string, first = false): Promise<User> => {
30 const r = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username,
34 email: `${username}@forge.local`,
35 password: "a-good-passphrase",
36 adminIfFirst: first,
37 });
38 if (!r.ok) throw new Error(r.error);
39 promote(app.db, r.value);
40 return r.value;
41 };
42 const admin = await mk("keeper", true);
43 const bob = await mk("bob");
44 const home = async (u?: User) =>
45 (
46 await fetch(`${servers.url}/`, {
47 headers: u ? { cookie: `forge_session=${createSession(app.db, u.id).token}; forge_mode=studio` } : {},
48 })
49 ).text();
50
51 const a = await home(admin);
52 expect(a).toContain('class="kpis"');
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(3);
54 for (const href of ["/board", "/releases", "/boards"]) expect(a).toContain(`class="card kpi" href="${href}"`);
55
56 // The figures count every repository, so a non-admin sees none.
57 expect(await home(bob)).not.toContain('class="kpis"');
58 expect(await home()).not.toContain('class="kpis"');
59 });
test/home-kpi-row.test.ts added +83
@@ -0,0 +1,83 @@
1 /** Home's figures row (#2871): the three tiles, their links, studio words only, a sparkline of 24 points. */
2 import { describe, expect, test } from "bun:test";
3 import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts";
4 import { running } from "../src/kernel/version.ts";
5 import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts";
6
7 const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0));
8
9 describe("home kpi row", () => {
10 test("three tiles with their links and figures", () => {
11 const out = kpiRowFor(
12 { landedHours: hours(6), landed: 6, live: null, medianCheckMs: 185_000 },
13 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
14 ).toString();
15 expect(out).toContain('<span class="sha">abcdef0</span>');
16 expect(out).toContain("Shipped per hour");
17 expect(out).toContain("0.3");
18 expect(out).toContain("6 changes in 24 h");
19 expect(out).toContain("abcdef0");
20 expect(out).not.toContain("abcdef01");
21 expect(out).toContain("3m 05s");
22 for (const href of ["/board", "/releases", "/boards"]) expect(out).toContain(`href="${href}"`);
23 });
24
25 test("empty figures say so", () => {
26 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString();
27 expect(out).toContain("Nothing live yet");
28 expect(out).toContain("No check finished in 24 h");
29 });
30
31 test("no git words render, not even in a title or label", () => {
32 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, medianCheckMs: 1000 }, null).toString();
33 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
34 });
35
36 test("the sparkline has one point per hour, oldest first", () => {
37 const svg = sparkline(hours(4)).toString();
38 const pts = /points="([^"]+)"/.exec(svg)![1]!.split(" ");
39 expect(pts).toHaveLength(24);
40 expect(pts[20]!.endsWith(",2.0")).toBe(true); // hour 3 ago is index 20, at the top
41 });
42
43 test("the live tile is the running commit, the same as /releases", () => {
44 const prev = process.env["FORGE_COMMIT"];
45 process.env["FORGE_COMMIT"] = "1234567abcdef";
46 try {
47 expect(liveNow()?.sha).toBe(running().commit!);
48 expect(liveNow()?.sha).toBe("1234567abcdef");
49 } finally {
50 if (prev === undefined) delete process.env["FORGE_COMMIT"];
51 else process.env["FORGE_COMMIT"] = prev;
52 }
53 });
54
55 test("a truncated note carries its whole text as a title", () => {
56 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString();
57 expect(out).toContain('title="No check finished in 24 h"');
58 });
59
60 test("check time reads as a person would", () => {
61 expect(checkTime(null)).toBe("—");
62 expect(checkTime(45_000)).toBe("45s");
63 expect(checkTime(3_720_000)).toBe("1h 02m");
64 });
65 });
66
67 describe("the terminal's figures", () => {
68 const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, medianCheckMs: 185_000 };
69 test("the same three figures in the same words", () => {
70 const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" });
71 expect(lines).toHaveLength(3);
72 expect(lines[0]).toContain("Shipped per hour 0.3");
73 expect(lines[0]).toContain("6 changes in 24 h");
74 expect(lines[1]).toContain("Live version abcdef0");
75 expect(lines[2]).toBe("Median check time 3m 05s");
76 expect(lines.join(" ")).not.toMatch(/commit|merge|branch|repositor|pull request/i);
77 });
78 test("the block sparkline has 24 cells, the busy hour tallest", () => {
79 const s = sparkText(hours(4));
80 expect([...s]).toHaveLength(24);
81 expect(s[20]).toBe("█");
82 });
83 });
test/tui-kpis.test.ts added +84
@@ -0,0 +1,84 @@
1 /** The terminal's first screen carries the figures the web home does (#2871): an admin sees them; nobody else does. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App as Forge } from "../src/app.ts";
7 import { createUser, type User } from "../src/core/users.ts";
8 import { createForgeApp } from "../src/tui/forge.ts";
9 import { headlessTerminal } from "../src/tui/term/terminal.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11 import { settleApp } from "./helpers/tui.ts";
12 import { seedAdmin } from "./helpers/admin.ts";
13
14 quietLogs();
15
16 let dir: string;
17 let forge: Forge;
18 let alice: User;
19 let bob: User;
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-tui-kpis-"));
23 forge = createApp({ dataDir: dir, port: 0 });
24 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
25 if (!a.ok) throw new Error("signup failed");
26 alice = a.value;
27 const b = await createUser(forge.db, {
28 origin: "seed",
29 trusted: true,
30 username: "bob",
31 email: "[email protected]",
32 password: "a-long-passphrase",
33 });
34 if (!b.ok) throw new Error(b.error);
35 bob = b.value;
36 });
37
38 afterAll(async () => {
39 forge.close();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 async function firstScreen(user?: User): Promise<string> {
44 const terminal = headlessTerminal(200, 32);
45 const { app } = createForgeApp(
46 forge,
47 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) },
48 { terminal, exitHooks: false },
49 );
50 app.start();
51 await settleApp(app);
52 const text = app.screen.toText();
53 app.stop();
54 return text;
55 }
56
57 async function withCommit<T>(commit: string | undefined, fn: () => Promise<T>): Promise<T> {
58 const saved = { c: process.env["FORGE_COMMIT"], a: process.env["FORGE_APP_COMMIT"] };
59 delete process.env["FORGE_APP_COMMIT"];
60 if (commit === undefined) delete process.env["FORGE_COMMIT"];
61 else process.env["FORGE_COMMIT"] = commit;
62 try {
63 return await fn();
64 } finally {
65 for (const [k, v] of [["FORGE_COMMIT", saved.c], ["FORGE_APP_COMMIT", saved.a]] as const) {
66 if (v === undefined) delete process.env[k];
67 else process.env[k] = v;
68 }
69 }
70 }
71
72 test("the terminal's first screen shows the three figures; a non-admin and a guest have none", async () => {
73 const text = await withCommit(undefined, () => firstScreen(alice));
74 expect(text).toContain("Shipped per hour 0.0");
75 expect(text).toContain("Live version — nothing live yet");
76 expect(text).toContain("Median check time —");
77 expect(await firstScreen(bob)).not.toContain("Shipped per hour");
78 expect(await firstScreen()).not.toContain("Shipped per hour");
79 });
80
81 test("the live figure is the running commit, as /releases shows it", async () => {
82 const text = await withCommit("1234567abcdef", () => firstScreen(alice));
83 expect(text).toContain("Live version 1234567, live");
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs