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

An app's About card in the terminal says how long its golden path took: New app to first preview, and to first production deploy #7514

Merged opened this

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

Merged

mason/forge:mason/7488-an-app-s-about-card-in-the-terminal-says → main 1 commit fork

  • Merged. mason/7488-an-app-s-about-card-in-the-terminal-says was merged into main as 9c6cf8c.

Review

Sign in to review this change.
2 files changed +158 −0
src/tui/screens/about.ts +19
@@ -29,6 +29,7 @@ import { listRefs } from "../../git/read.ts";
2929 import { directionNote } from "../../crew/direct.ts";
3030 import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts";
3131 import { showProvenance } from "../../actions/provenance.ts";
32 import { appPace } from "../../actions/apps.ts";
3233 import {
3334 checkLine,
3435 checkedWords,
@@ -107,6 +108,7 @@ export function aboutScreen(
107108 ...flowRow(repo),
108109 ...outsidersRow(repo),
109110 ...aboutFacts(f, src, facts, ref),
111 ...paceRows(f, repo),
110112 ...trailRows(f, repo),
111113 ...upstreamRows(f, repo),
112114 ...offerRows(offered),
@@ -446,6 +448,23 @@ export function trailRows(
446448 ];
447449 }
448450
451 /**
452 * How long an app's golden path took, in the apps page's words (#7488):
453 * nothing on a repository that is not an app, or before either figure exists.
454 */
455 export function paceRows(
456 f: Session,
457 repo: Repo | undefined,
458 ): [string, string][] {
459 if (repo === undefined) return [];
460 const pace = appPace(f.app, f.user, repo);
461 if (pace === "") return [];
462 return wrap(pace, 50).map((line, i): [string, string] => [
463 i === 0 ? "golden path" : "",
464 line,
465 ]);
466 }
467
449468 /** The offer row: what the fork has for its parent, and where it stands. */
450469 export function offerRows(offer: UpstreamOffer | null): [string, string][] {
451470 if (offer === null) return [];
test/tui-about-golden.test.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The About card's golden path row (#7488): an app's first preview and first
3 * production deploy, in `goldenPathLine`'s words; nothing on a fresh app or a
4 * repository that is not an app.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp as insertApp } from "../src/core/apps.ts";
13 import { getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { aboutScreen, paceRows } from "../src/tui/screens/about.ts";
16 import type { Ctx, Screen } from "../src/tui/app/app.ts";
17 import type { Session } from "../src/tui/session.ts";
18 import { Screen as Surface } from "../src/tui/term/screen.ts";
19 import { theme } from "../src/tui/ui/theme.ts";
20 import { localSource } from "../src/view/local.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 const T0 = Date.parse("2026-01-01T00:00:00.000Z");
26 const at = (ms: number): string => new Date(T0 + ms).toISOString();
27
28 let dataDir = "";
29 let app: App;
30 let alice: User;
31 let n = 0;
32
33 const repoNamed = async (): Promise<Repo> => {
34 n += 1;
35 const made = await createRepo(app, alice, { name: `golden${n}`, visibility: "private" });
36 if (!made.ok) throw new Error(made.error);
37 return made.value;
38 };
39 const asApp = (repo: Repo): void => {
40 const made = insertApp(app.db, { repoId: repo.id, slug: repo.name, access: "private" });
41 if (!made.ok) throw new Error(made.error);
42 app.db.query("UPDATE app SET created_at = ?1 WHERE id = ?2").run(at(0), made.value.id);
43 };
44 const deploy = (repo: Repo, env: string, created: number, serving: number): void => {
45 app.db
46 .query(
47 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
48 VALUES (?1, ?2, 'deploy', ?3, 'ok', ?4, ?5)`,
49 )
50 .run(`r${Math.random()}`, repo.id, env, at(created), at(serving));
51 };
52 const merge = (repo: Repo, mergedAt: number): void => {
53 app.db
54 .query(`INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, 1, 'pull', 'p', ?3)`)
55 .run(`i${repo.id}`, repo.id, alice.id);
56 app.db
57 .query(
58 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
59 VALUES (?1, ?2, 'h', 'a', 'b', ?3)`,
60 )
61 .run(`i${repo.id}`, repo.defaultBranch, at(mergedAt));
62 };
63 const session = (user: User | undefined): Session =>
64 ({ app, db: app.db, user }) as unknown as Session;
65 const rowsOf = (repo: Repo, user: User = alice): [string, string][] =>
66 paceRows(session(user), getRepoById(app.db, repo.id));
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-about-golden-"));
70 app = createApp({ dataDir, port: 0 });
71 const made = await createUser(app.db, {
72 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
73 password: "a-good-passphrase", adminIfFirst: true,
74 });
75 if (!made.ok) throw new Error(made.error);
76 alice = made.value;
77 });
78
79 afterAll(async () => {
80 app?.close();
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 test("an app with a first preview and a first production deploy draws both on one line", async () => {
85 const repo = await repoNamed();
86 asApp(repo);
87 deploy(repo, "pull-1", 60_000, 180_000);
88 merge(repo, 30 * 60_000);
89 deploy(repo, "production", 31 * 60_000, 60 * 60_000);
90 expect(rowsOf(repo)).toEqual([
91 ["golden path", "first preview 3m after New app · in production 1h"],
92 ]);
93 });
94
95 test("a figure that does not exist yet is left out", async () => {
96 const repo = await repoNamed();
97 asApp(repo);
98 deploy(repo, "pull-1", 1000, 14_000);
99 expect(rowsOf(repo)).toEqual([["golden path", "first preview 14s after New app"]]);
100 });
101
102 test("a fresh app, a repository that is not an app, and a stranger draw nothing", async () => {
103 const fresh = await repoNamed();
104 asApp(fresh);
105 expect(rowsOf(fresh)).toEqual([]);
106 const plain = await repoNamed();
107 deploy(plain, "pull-1", 1000, 2000);
108 expect(rowsOf(plain)).toEqual([]);
109 const hidden = await repoNamed();
110 asApp(hidden);
111 deploy(hidden, "pull-1", 1000, 2000);
112 const bob = await createUser(app.db, {
113 origin: "seed", trusted: true, username: "bob", email: "[email protected]",
114 password: "a-good-passphrase",
115 });
116 if (!bob.ok) throw new Error(bob.error);
117 expect(rowsOf(hidden, bob.value)).toEqual([]);
118 });
119
120 test("the About card draws the row on an app and not on a plain repository", async () => {
121 const ctx = {
122 theme: theme("dark"),
123 invalidate() {}, toast() {}, push() {}, pop() {}, copy() {},
124 } as unknown as Ctx;
125 const draw = (repo: Repo): string => {
126 const f = session(alice);
127 const src = localSource(app, getRepoById(app.db, repo.id)!, alice);
128 const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main");
129 const s = new Surface(110, 40);
130 screen.render({ x: 0, y: 0, w: 110, h: 40 }, s);
131 return s.toText();
132 };
133 const shown = await repoNamed();
134 asApp(shown);
135 deploy(shown, "pull-1", 1000, 120_000);
136 expect(draw(shown)).toContain("first preview 2m after New app");
137 const plain = await repoNamed();
138 expect(draw(plain)).not.toContain("golden path");
139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs