Public: anyone can read this; the crew can change it.
A test pins how a run reads in the terminal: each run state's tone, the environment in words and dollars to the cent #7603
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7594-a-test-pins-how-a-run-reads-in-the-termi → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +144
−1
src/core/runs.ts +3 −1
| @@ -25,7 +25,9 @@ export const STOPPED_SUMMARY = "the forge stopped"; | ||
| 25 | 25 | /** The kinds that pass judgement on a commit: a preview's deploy, or a repository's own check. */ |
| 26 | 26 | export const CHECK_KINDS: readonly RunKind[] = ["deploy", "check"]; |
| 27 | 27 | export const RUN_KINDS: readonly RunKind[] = ["deploy", "agent", "check"]; |
| 28 | export type RunState = "queued" | "running" | "ok" | "failed" | "cancelled"; | |
| 28 | /** Every state a run can be in — the type is read off this list, so a new state is named here once. */ | |
| 29 | export const RUN_STATES = ["queued", "running", "ok", "failed", "cancelled"] as const; | |
| 30 | export type RunState = (typeof RUN_STATES)[number]; | |
| 29 | 31 | |
| 30 | 32 | export const RUN_TERMINAL: readonly RunState[] = ["ok", "failed", "cancelled"]; |
| 31 | 33 | export const isTerminal = (state: RunState): boolean => |
test/tui-runwords.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * How a run reads in the terminal (#7594): every state's tone, the | |
| 3 | * environment in words, the dollars to the cent — and the runs list and the | |
| 4 | * run log saying the same words for one run. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { RUN_STATES, claimRun, createRun, finishRun, type Run, type RunState } from "../src/core/runs.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import type { User } from "../src/core/users.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { runLogScreen } from "../src/tui/screens/runlog.ts"; | |
| 17 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 18 | import { costText, envLabel, runTone } from "../src/tui/screens/runwords.ts"; | |
| 19 | import type { Screen } from "../src/tui/app/app.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import type { Tone } from "../src/tui/ui/widgets.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | /** | |
| 29 | * The tone each state wears. A `Record` over `RunState`, so a state added to | |
| 30 | * `RUN_STATES` without a row here fails the type check, and the loop below | |
| 31 | * fails the test. `queued` has no tone on purpose: nothing has happened yet. | |
| 32 | */ | |
| 33 | const TONES: Record<RunState, Tone | undefined> = { | |
| 34 | queued: undefined, | |
| 35 | running: "attn", | |
| 36 | ok: "success", | |
| 37 | failed: "danger", | |
| 38 | cancelled: "secondary", | |
| 39 | }; | |
| 40 | ||
| 41 | describe("runTone", () => { | |
| 42 | test("RUN_STATES names each state once", () => { | |
| 43 | expect(new Set(RUN_STATES).size).toBe(RUN_STATES.length); | |
| 44 | expect(RUN_STATES.length).toBeGreaterThan(0); | |
| 45 | }); | |
| 46 | ||
| 47 | for (const state of RUN_STATES) { | |
| 48 | test(`${state} wears its tone`, () => { | |
| 49 | expect(Object.hasOwn(TONES, state)).toBe(true); | |
| 50 | expect(runTone(state)).toBe(TONES[state]); | |
| 51 | }); | |
| 52 | } | |
| 53 | ||
| 54 | test("only a queued run goes without a tone", () => { | |
| 55 | expect(RUN_STATES.filter((s) => runTone(s) === undefined)).toEqual(["queued"]); | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 59 | describe("envLabel", () => { | |
| 60 | test("a pull's environment reads as the pull", () => { | |
| 61 | expect(envLabel("pull-4")).toBe("pull #4"); | |
| 62 | expect(envLabel("pull-7594")).toBe("pull #7594"); | |
| 63 | }); | |
| 64 | test("a named environment reads as its name", () => { | |
| 65 | expect(envLabel("production")).toBe("production"); | |
| 66 | }); | |
| 67 | test("no environment reads as a dash", () => { | |
| 68 | expect(envLabel("")).toBe("—"); | |
| 69 | }); | |
| 70 | }); | |
| 71 | ||
| 72 | describe("costText", () => { | |
| 73 | test("zero spent says nothing", () => { | |
| 74 | expect(costText(0)).toBe(""); | |
| 75 | }); | |
| 76 | // Rounded to the cent, as the web studio cards and the runner's line do: | |
| 77 | // a sub-cent run reads $0.00, not nothing, because it did spend. | |
| 78 | test("sub-cent rounds to the cent", () => { | |
| 79 | expect(costText(0.004)).toBe("$0.00"); | |
| 80 | expect(costText(0.005)).toBe("$0.01"); | |
| 81 | }); | |
| 82 | test("cents read to the cent", () => { | |
| 83 | expect(costText(0.07)).toBe("$0.07"); | |
| 84 | expect(costText(0.4)).toBe("$0.40"); | |
| 85 | expect(costText(3.456)).toBe("$3.46"); | |
| 86 | }); | |
| 87 | test("thousands read whole, to the cent, with no separator", () => { | |
| 88 | expect(costText(1234.5)).toBe("$1234.50"); | |
| 89 | expect(costText(12000)).toBe("$12000.00"); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("the runs list and the run log read one run alike", () => { | |
| 94 | let dir: string; | |
| 95 | let forge: Forge; | |
| 96 | let repo: Repo; | |
| 97 | let alice: User; | |
| 98 | let run: Run; | |
| 99 | ||
| 100 | beforeAll(async () => { | |
| 101 | dir = await mkdtemp(join(tmpdir(), "forge-tui-runwords-")); | |
| 102 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 103 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 104 | if (!a.ok) throw new Error("signup failed"); | |
| 105 | alice = a.value; | |
| 106 | const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" }); | |
| 107 | if (!r.ok) throw new Error("repo failed"); | |
| 108 | repo = r.value; | |
| 109 | run = createRun(forge.db, { repoId: repo.id, kind: "deploy", env: "pull-4" }); | |
| 110 | claimRun(forge.db, run.id); | |
| 111 | finishRun(forge.db, run.id, { state: "failed", summary: "LAMP-BROKE", costUsd: 1234.5 }); | |
| 112 | }); | |
| 113 | afterAll(async () => { | |
| 114 | forge.close?.(); | |
| 115 | await rm(dir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | const draw = async (screen: (f: Parameters<typeof runsScreen>[0], ctx: Parameters<typeof runsScreen>[1]) => Screen) => { | |
| 119 | const { app, session } = createForgeApp( | |
| 120 | forge, | |
| 121 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice }, | |
| 122 | { terminal: headlessTerminal(140, 20), exitHooks: false }, | |
| 123 | ); | |
| 124 | app.start(); | |
| 125 | await settleApp(app); | |
| 126 | app.push(screen(session, app.ctx)); | |
| 127 | await settleApp(app); | |
| 128 | const text = app.screen.toText(); | |
| 129 | app.stop(); | |
| 130 | return text; | |
| 131 | }; | |
| 132 | ||
| 133 | test("both say the state, the environment and the dollars in the same words", async () => { | |
| 134 | const list = await draw((f, ctx) => runsScreen(f, ctx, repo)); | |
| 135 | const log = await draw((f, ctx) => runLogScreen(f, ctx, repo, run.id)); | |
| 136 | for (const words of ["failed", "deploy pull #4", "$1234.50", "LAMP-BROKE"]) { | |
| 137 | expect(list).toContain(words); | |
| 138 | expect(log).toContain(words); | |
| 139 | } | |
| 140 | }); | |
| 141 | }); |
new above
offline
Next: this uniqueness test is nearly tautological for a literal tuple; drop it or fold it into the loop below.