Public: anyone can read this; the crew can change it.
The terminal's usage screen says what the crew's tokens bought: dollars, runs, changes landed and cost per change, as MCP `returns` does #7283
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7271-the-terminal-s-usage-screen-says-what-th → main 6 commits fork 4 threads · 4 unresolved
9 files
changed +236
−15
README.md +2
| @@ -429,6 +429,8 @@ This page is the front door. Everything else is a file of its own under | ||
| 429 | 429 | - [](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 430 | 430 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 431 | 431 | |
| 432 | What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. Owner facts only (`src/actions/returns.ts`); see [](docs/OPERATING.md). | |
| 433 | ||
| 432 | 434 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do (`docs/CREW.md`, "Where you are signed in"). |
| 433 | 435 | |
| 434 | 436 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`). |
docs/OPERATING.md +1 −1
| @@ -163,7 +163,7 @@ A forge admin opens the palette (`ctrl+k`), picks **Secrets**, and sees the seal | ||
| 163 | 163 | |
| 164 | 164 | A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token. The page and the terminal screen both list flags through one read, `readFlags` in `src/actions/flags_view.ts`. |
| 165 | 165 | |
| 166 | Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either. | |
| 166 | Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list under a Returns line: what the crew's tokens bought over the last 7 days (dollars, runs, changes landed, the median cost of a landed change), as MCP `returns` and `/board/returns` read it; `[` and `]` move the window between 1, 7, 30 and 90 days, and a window with no runs reads "nothing spent". The palette's Usage of a repository (`owner/name`) shows that line for one app alone, as `repo` does in MCP, with no routes (they are counted forge-wide); it is for whoever may read the repository's owner facts, and anyone `returns` refuses sees no line and no window keys. The spend breakdown, capacity and telemetry tabs are web-only; an agent is never an admin there either. | |
| 167 | 167 | |
| 168 | 168 | Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people. |
| 169 | 169 |
src/core/money.ts added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | /** Dollars as every surface writes them, the board, its returns page and the terminal alike. */ | |
| 2 | ||
| 3 | /** Cents under ten, whole above. */ | |
| 4 | export const usd = (n: number): string => `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`; |
src/tui/forge.ts +6
| @@ -125,6 +125,12 @@ const REPO_VERBS: readonly { | ||
| 125 | 125 | title: "Milestones in a repository", |
| 126 | 126 | open: (f, ctx, repo) => limbsScreen(f, ctx, repo), |
| 127 | 127 | }, |
| 128 | { | |
| 129 | // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271). | |
| 130 | id: "repo-usage", | |
| 131 | title: "Usage of a repository", | |
| 132 | open: (f, ctx, repo) => usageScreen(f, ctx, repo), | |
| 133 | }, | |
| 128 | 134 | { id: "new-issue", title: "New issue", open: newIssueScreen }, |
| 129 | 135 | { id: "new-pull", title: "New pull request", open: newPullScreen }, |
| 130 | 136 | { |
src/tui/screens/usage.ts +61 −10
| @@ -4,11 +4,20 @@ | ||
| 4 | 4 | * first with their last use or `never`, as Settings → Usage lists them |
| 5 | 5 | * (`routeUsage`). The gate is the page's: an admin who is not an agent; |
| 6 | 6 | * anyone else sees nothing. |
| 7 | * | |
| 8 | * Above it, what the crew's tokens bought (#7271), as MCP `returns` reads | |
| 9 | * it (`readReturns`): dollars, runs, changes landed and cost per change over | |
| 10 | * 1, 7, 30 or 90 days, `[`/`]` to move. A repository's usage narrows it to | |
| 11 | * that app and lists no routes; someone `readReturns` refuses sees no block. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | 14 | import { USAGE_ADMIN_ONLY } from "../../actions/admin_limits_words.ts"; |
| 15 | import { readReturns } from "../../actions/returns.ts"; | |
| 10 | 16 | import { mayManageFlags } from "../../core/flags.ts"; |
| 11 | 17 | import { ROLES, SURFACES } from "../../core/usage_count.ts"; |
| 18 | import { usd } from "../../core/money.ts"; | |
| 19 | import type { Repo } from "../../core/repos.ts"; | |
| 20 | import type { Returns } from "../../core/returns.ts"; | |
| 12 | 21 | import { routeUsage, WINDOW_DAYS, type RouteUsage } from "../../core/usage_report.ts"; |
| 13 | 22 | import { knownPatterns } from "../../http/server.ts"; |
| 14 | 23 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -30,24 +39,66 @@ export function usageRows(r: readonly RouteUsage[]): ListRow[] { | ||
| 30 | 39 | })); |
| 31 | 40 | } |
| 32 | 41 | |
| 33 | export function usageScreen(f: Session, ctx: Ctx): Screen { | |
| 42 | /** The windows `[`/`]` move between, in days; 7 is where the screen opens. */ | |
| 43 | export const RETURNS_SPANS = [1, 7, 30, 90] as const; | |
| 44 | ||
| 45 | const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 46 | ||
| 47 | /** The returns block's one line, in words: "nothing spent" when no run ran, never a NaN. */ | |
| 48 | export function returnsLine(r: Returns, days: number): string { | |
| 49 | const span = `Returns, ${plural(days, "day", "days")}: `; | |
| 50 | if (r.spent.runs === 0) return `${span}nothing spent · ${plural(r.landed, "change", "changes")} landed`; | |
| 51 | const per = r.onLanded.perChange; | |
| 52 | return `${span}${usd(r.spent.usd)} over ${plural(r.spent.runs, "run", "runs")} · ${plural(r.landed, "change", "changes")} landed · ${ | |
| 53 | per.changes === 0 ? "no landed change to cost" : `${usd(per.median)} a landed change (median)` | |
| 54 | }`; | |
| 55 | } | |
| 56 | ||
| 57 | export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen { | |
| 34 | 58 | const state = new ListState(); |
| 35 | const allowed = mayManageFlags(f.user); | |
| 59 | const allowed = repo === undefined && mayManageFlags(f.user); | |
| 36 | 60 | // Read once when the screen opens: the report scans the whole table, so a frame or a key never re-runs it. |
| 37 | 61 | const rows: RouteUsage[] = allowed ? routeUsage(f.db, knownPatterns(), new Date()) : []; |
| 62 | const scope = repo === undefined ? {} : { repo: `${repo.ownerName}/${repo.name}` }; | |
| 63 | let span = RETURNS_SPANS.indexOf(7); | |
| 64 | // Read when the window moves, never per frame; undefined is a refusal, and then there is no block. | |
| 65 | const read = (): string | undefined => { | |
| 66 | const days = RETURNS_SPANS[span]!; | |
| 67 | const r = readReturns(f.app, f.user, { ...scope, days }); | |
| 68 | return r.ok ? returnsLine(r.value, days) : undefined; | |
| 69 | }; | |
| 70 | let returns = read(); | |
| 38 | 71 | return { |
| 39 | title: "Usage", | |
| 40 | keys: [binding(["j", "k"], "move cursor")], | |
| 72 | title: repo === undefined ? "Usage" : `Usage · ${repo.ownerName}/${repo.name}`, | |
| 73 | keys: [ | |
| 74 | binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }), | |
| 75 | binding(["[", "]"], "returns window", { hidden: returns === undefined }), | |
| 76 | ], | |
| 41 | 77 | render(area, s) { |
| 42 | 78 | const c = ctx.theme.c; |
| 43 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 79 | const [top, head, list] = split(area, "v", [len(returns === undefined ? 0 : 1), len(1), fill(1)]); | |
| 80 | if (returns !== undefined) | |
| 81 | s.text(top!.x, top!.y, truncate(returns, top!.w), style(c.fg, c.bg), top!.x + top!.w); | |
| 44 | 82 | const all = rows; |
| 45 | const text = !allowed | |
| 46 | ? USAGE_ADMIN_ONLY | |
| 47 | : `${all.filter((r) => r.total === 0).length} of ${all.length} routes had no request in ${WINDOW_DAYS} days; unused first.`; | |
| 83 | const text = | |
| 84 | repo !== undefined | |
| 85 | ? returns === undefined ? "Nothing here is yours to see." : "Routes are counted forge-wide: Usage, without a repository." | |
| 86 | : !allowed | |
| 87 | ? USAGE_ADMIN_ONLY | |
| 88 | : `${all.filter((r) => r.total === 0).length} of ${all.length} routes had no request in ${WINDOW_DAYS} days; unused first.`; | |
| 48 | 89 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 49 | drawList(s, list!, c, state, usageRows(all)); | |
| 90 | // A repository's usage has no routes to list: no empty pane under the line. | |
| 91 | if (repo === undefined) drawList(s, list!, c, state, usageRows(all)); | |
| 92 | }, | |
| 93 | key: (e) => { | |
| 94 | if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) { | |
| 95 | const next = Math.max(0, Math.min(RETURNS_SPANS.length - 1, span + (e.key === "]" ? 1 : -1))); | |
| 96 | if (next === span) return true; | |
| 97 | span = next; | |
| 98 | returns = read(); | |
| 99 | return true; | |
| 100 | } | |
| 101 | return state.key(e, rows.length); | |
| 50 | 102 | }, |
| 51 | key: (e) => state.key(e, rows.length), | |
| 52 | 103 | }; |
| 53 | 104 | } |
src/web/pages/board.ts +1 −2
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * pointer. The old big view (`/big`, every address it had) lands here. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { usd } from "../../core/money.ts"; | |
| 17 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 18 | 19 | import { sentinelNotice } from "../../crew/sentinel.ts"; |
| 19 | 20 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| @@ -466,8 +467,6 @@ function asksStrip(ctx: Ctx): Html | null { | ||
| 466 | 467 | |
| 467 | 468 | // ---- what the tokens bought ---- |
| 468 | 469 | |
Non-blocking: removing |
||
| 469 | /** Dollars as the board writes them: cents under ten, whole above. */ | |
| 470 | export const usd = (n: number): string => `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`; | |
| 471 | 470 | |
Nit: removing |
||
| 472 | 471 | /** |
| 473 | 472 | * The week's spend against what it landed, in one line, owner facts only: |
src/web/pages/returns.ts +1 −1
| @@ -15,7 +15,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { page } from "../layout.ts"; |
| 17 | 17 | import { card, listCard, row } from "../ui.ts"; |
| 18 | import { usd } from "./board.ts"; | |
| 18 | import { usd } from "../../core/money.ts"; | |
| 19 | 19 | |
| 20 | 20 | const runs = (m: Money): string => `${m.runs} ${m.runs === 1 ? "run" : "runs"}`; |
| 21 | 21 |
test/tui-report.test.ts +2 −1
| @@ -74,7 +74,8 @@ afterAll(async () => { | ||
| 74 | 74 | |
| 75 | 75 | describe("report a problem in the terminal", () => { |
| 76 | 76 | test("the palette has the row", async () => { |
| 77 | expect(await palette(bob, [":"])).toContain("alice/lobby — Report a problem"); | |
| 77 | // Filtered, so the row's presence does not depend on how many others the palette lists. | |
| 78 | expect(await palette(bob, [":", "Report a"])).toContain("alice/lobby — Report a problem"); | |
| 78 | 79 | }); |
| 79 | 80 | |
| 80 | 81 | test("a text opens an issue titled by its first line", async () => { |
test/tui-usage-returns.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * The terminal's usage screen says what the crew's tokens bought (#7271), as | |
| 3 | * MCP `returns` reads it: dollars, runs, changes landed and cost per change | |
| 4 | * over 7 days, `[`/`]` to 1, 30 and 90; a repository's usage narrows to that | |
| 5 | * app; someone `returns` refuses sees no block; no runs is "nothing spent". | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, 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 { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createRun } from "../src/core/runs.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | const DAY = 86_400_000; | |
| 29 | ||
| 30 | function boot(username: string) { | |
| 31 | const user = getUserByName(forge.db, username)!; | |
| 32 | const { app } = createForgeApp( | |
| 33 | forge, | |
| 34 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 35 | { terminal: headlessTerminal(160, 32), exitHooks: false }, | |
| 36 | ); | |
| 37 | app.start(); | |
| 38 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 39 | const type = async (word: string) => { | |
| 40 | for (const ch of word) key(ch); | |
| 41 | await settleApp(app); | |
| 42 | }; | |
| 43 | const enter = async () => { | |
| 44 | key("enter"); | |
| 45 | await settleApp(app); | |
| 46 | }; | |
| 47 | return { | |
| 48 | text: () => app.screen.toText(), | |
| 49 | key: async (k: string) => { | |
| 50 | key(k); | |
| 51 | await settleApp(app); | |
| 52 | }, | |
| 53 | /** A palette command, and the `owner/name` its prompt asks for when it asks. */ | |
| 54 | open: async (command: string, spec?: string) => { | |
| 55 | key("k", true); | |
| 56 | await type(command); | |
| 57 | await enter(); | |
| 58 | if (spec !== undefined) { | |
| 59 | await type(spec); | |
| 60 | await enter(); | |
| 61 | } | |
| 62 | }, | |
| 63 | stop: () => app.stop(), | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | /** One agent run on a new issue, ended `ago` days back; with `merged`, its pull merged then too. */ | |
| 68 | async function spend(repo: Repo, usd: number, ago: number, merged?: string): Promise<void> { | |
| 69 | const alice = getUserByName(forge.db, "alice")!; | |
| 70 | const bot = getUserByName(forge.db, "bot")!; | |
| 71 | const issue = openIssue(forge, alice, repo, { title: `work ${usd}` }); | |
| 72 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 73 | const at = new Date(Date.now() - ago * DAY - 60_000).toISOString(); | |
| 74 | const made = createRun(forge.db, { | |
| 75 | repoId: repo.id, kind: "agent", issueId: issue.value.id, branch: merged ?? "", actorId: bot.id, reason: "assigned", | |
| 76 | }); | |
| 77 | forge.db | |
| 78 | .query("UPDATE run SET state = 'ok', cost_usd = ?2, summary = 'ok', created_at = ?3, started_at = ?3, finished_at = ?3 WHERE id = ?1") | |
| 79 | .run(made.id, usd, at); | |
| 80 | if (merged === undefined) return; | |
| 81 | await commitToBranch(forge, alice, repo, { | |
| 82 | branch: merged, base: "main", message: merged, files: [{ path: `${merged.replace(/\W/g, "-")}.md`, content: "work\n" }], | |
| 83 | }); | |
| 84 | const pull = await openPull(forge, bot, repo, { | |
| 85 | title: `Work on #${issue.value.number}`, body: `Closes #${issue.value.number}`, baseBranch: "main", headBranch: merged, | |
| 86 | }); | |
| 87 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 88 | forge.db.query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1").run(pull.value.id, at); | |
| 89 | } | |
| 90 | ||
| 91 | beforeAll(async () => { | |
| 92 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-usage-returns-")); | |
| 93 | forge = createApp({ dataDir, port: 0 }); | |
| 94 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 95 | const made = await createUser(forge.db, { | |
| 96 | origin: "seed", | |
| 97 | trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst, | |
| 98 | }); | |
| 99 | if (!made.ok) throw new Error(made.error); | |
| 100 | } | |
| 101 | const alice = getUserByName(forge.db, "alice")!; | |
| 102 | await createAgent(forge, alice, { username: "bot" }); | |
| 103 | const repos: Repo[] = []; | |
| 104 | for (const name of ["mill", "kiln", "empty"]) { | |
| 105 | const made = await createRepo(forge, alice, { name, visibility: "public" }); | |
| 106 | if (!made.ok) throw new Error(String(made.error)); | |
| 107 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "README.md", content: `# ${name}\n` }] }); | |
| 108 | addToCrew(forge, alice, made.value, "bot"); | |
| 109 | repos.push(made.value); | |
| 110 | } | |
| 111 | const [mill, kiln] = repos; | |
| 112 | await spend(mill!, 1, 0, "bot/1-landed"); // this week: $1, landed | |
| 113 | await spend(mill!, 0.5, 1); // this week: $0.50, nothing merged | |
| 114 | await spend(mill!, 3, 20, "bot/3-old"); // this month: $3, landed | |
| 115 | await spend(kiln!, 5, 2); // this week, another app: $5, nothing merged | |
| 116 | }, 30_000); | |
| 117 | afterAll(async () => { | |
| 118 | forge.close(); | |
| 119 | await rm(dataDir, { recursive: true, force: true }); | |
| 120 | }); | |
| 121 | ||
| 122 | test("the owner's usage says what 7 days bought, and ] widens it to 30", async () => { | |
| 123 | const h = boot("alice"); | |
| 124 | await h.open("Usage"); | |
| 125 | expect(h.text()).toContain("Returns, 7 days: $6.50 over 3 runs · 1 change landed · $1.00 a landed change (median)"); | |
| 126 | await h.key("]"); | |
| 127 | expect(h.text()).toContain("Returns, 30 days: $9.50 over 4 runs · 2 changes landed · $3.00 a landed change (median)"); | |
| 128 | await h.key("["); | |
| 129 | await h.key("["); | |
| 130 | expect(h.text()).toContain("Returns, 1 day: $1.00 over 1 run · 1 change landed · $1.00 a landed change (median)"); | |
| 131 | h.stop(); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a repository's usage narrows to that app, and no runs reads nothing spent", async () => { | |
| 135 | const h = boot("alice"); | |
| 136 | await h.open("Usage of a repository", "alice/kiln"); | |
| 137 | expect(h.text()).toContain("Returns, 7 days: $5.00 over 1 run · 0 changes landed · no landed change to cost"); | |
| 138 | expect(h.text()).not.toContain("routes had no request"); | |
| 139 | h.stop(); | |
| 140 | const e = boot("alice"); | |
| 141 | await e.open("Usage of a repository", "alice/empty"); | |
| 142 | expect(e.text()).toContain("Returns, 7 days: nothing spent · 0 changes landed"); | |
| 143 | expect(e.text()).not.toContain("NaN"); | |
| 144 | e.stop(); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a reader the returns tool refuses sees no returns block", async () => { | |
| 148 | const h = boot("bob"); | |
| 149 | await h.open("Usage of a repository", "alice/mill"); | |
| 150 | expect(h.text()).toContain("Usage · alice/mill"); | |
| 151 | expect(h.text()).not.toContain("Returns,"); | |
| 152 | expect(h.text()).not.toContain("$"); | |
| 153 | const before = h.text(); | |
| 154 | await h.key("]"); | |
| 155 | await h.key("["); | |
| 156 | expect(h.text()).toBe(before); | |
| 157 | h.stop(); | |
| 158 | }); |
new above
offline
Importing
usdfrom the web page module couples the TUI to the HTML layer. Move it to a shared core module and import it from there in both places.