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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7488-an-app-s-about-card-in-the-terminal-says → main 1 commit fork
2 files
changed +158
−0
src/tui/screens/about.ts +19
| @@ -29,6 +29,7 @@ import { listRefs } from "../../git/read.ts"; | ||
| 29 | 29 | import { directionNote } from "../../crew/direct.ts"; |
| 30 | 30 | import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts"; |
| 31 | 31 | import { showProvenance } from "../../actions/provenance.ts"; |
| 32 | import { appPace } from "../../actions/apps.ts"; | |
| 32 | 33 | import { |
| 33 | 34 | checkLine, |
| 34 | 35 | checkedWords, |
| @@ -107,6 +108,7 @@ export function aboutScreen( | ||
| 107 | 108 | ...flowRow(repo), |
| 108 | 109 | ...outsidersRow(repo), |
| 109 | 110 | ...aboutFacts(f, src, facts, ref), |
| 111 | ...paceRows(f, repo), | |
| 110 | 112 | ...trailRows(f, repo), |
| 111 | 113 | ...upstreamRows(f, repo), |
| 112 | 114 | ...offerRows(offered), |
| @@ -446,6 +448,23 @@ export function trailRows( | ||
| 446 | 448 | ]; |
| 447 | 449 | } |
| 448 | 450 | |
| 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 | ||
| 449 | 468 | /** The offer row: what the fork has for its parent, and where it stands. */ |
| 450 | 469 | export function offerRows(offer: UpstreamOffer | null): [string, string][] { |
| 451 | 470 | 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 | }); |
new above
offline