Public: anyone can read this; the crew can change it.
The terminal's one-commit view says the commit's check: green, red or not run, and why, as MCP `commits` with `oid` does #7310
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7300-the-terminal-s-one-commit-view-says-the → main 2 commits fork
5 files
changed +391
−6
README.md +2
| @@ -56,6 +56,8 @@ On a sealed issue, a writer gets the private repository to fix it in: `POST /api | ||
| 56 | 56 | |
| 57 | 57 | **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet. |
| 58 | 58 | |
| 59 | The terminal's history says each commit's check, as MCP `commits` with `oid` does: a ✓ or ✗ mark on a log row where the commit's own check on the default branch passed or failed (blank where it has no verdict yet), and a `check:` line under one commit's title: `green`, `red · <first line of the failing step>`, `running`, `superseded` or `not run`, with `· via <sha>` when a later main commit's check answers for it. Nothing shows to a viewer who may not read the repository's runs (`src/checks/commit_check.ts`, `src/tui/screens/commits.ts`). | |
| 60 | ||
| 59 | 61 | Under the agents, each team that has a pull has one pooled row (its members' |
| 60 | 62 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 61 | 63 | both): `data-team` rows on the page, "team <name>" rows in that terminal screen. |
src/actions/runs.ts +12 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * the system's own voice and takes no actor. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts"; | |
| 11 | import { commitVerdicts, isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts"; | |
| 12 | 12 | import { hasCommit, isAncestor } from "../git/local.ts"; |
| 13 | 13 | import { repoDir } from "../git/repos.ts"; |
| 14 | 14 | import { mayViewId } from "../core/sealed.ts"; |
| @@ -191,6 +191,17 @@ export async function checkOfCommit( | ||
| 191 | 191 | ); |
| 192 | 192 | } |
| 193 | 193 | |
| 194 | /** The own green or red verdicts of a page of commits, for whoever may read the runs; empty otherwise. */ | |
| 195 | export function verdictsOfCommits( | |
| 196 | app: App, | |
| 197 | actor: User | undefined, | |
| 198 | repo: Repo, | |
| 199 | oids: readonly string[], | |
| 200 | ): Map<string, CommitCheck> { | |
| 201 | if (!mayReadRuns(app, actor, repo)) return new Map(); | |
| 202 | return commitVerdicts(app.db, repo.id, repo.defaultBranch, oids); | |
| 203 | } | |
| 204 | ||
| 194 | 205 | /** A run by id, only if it belongs to a repository the actor can see. */ |
| 195 | 206 | export function getRepoRun( |
| 196 | 207 | app: App, |
src/checks/commit_check.ts +40
| @@ -87,6 +87,46 @@ export function commitCheck( | ||
| 87 | 87 | }; |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | /** | |
| 91 | * The own verdicts, `ok` or `failed`, of a page of commits in one query: what | |
| 92 | * a log list marks. A commit with no verdict yet is absent from the map. | |
| 93 | */ | |
| 94 | export function commitVerdicts( | |
| 95 | db: Database, | |
| 96 | repoId: string, | |
| 97 | branch: string, | |
| 98 | oids: readonly string[], | |
| 99 | ): Map<string, CommitCheck> { | |
| 100 | const out = new Map<string, CommitCheck>(); | |
| 101 | const wanted = new Set(oids.filter(isCommitId)); | |
| 102 | if (wanted.size === 0) return out; | |
| 103 | const rows = db | |
| 104 | .query< | |
| 105 | { id: string; commit_oid: string; state: string; summary: string; finished_at: string | null }, | |
| 106 | [string, string, number] | |
| 107 | >( | |
| 108 | `SELECT id, commit_oid, state, summary, finished_at | |
| 109 | FROM (SELECT id, kind, issue_id, env, commit_oid, state, summary, finished_at | |
| 110 | FROM run WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?3) | |
| 111 | WHERE kind = 'check' AND issue_id IS NULL AND env = ?2 | |
| 112 | AND state IN ('ok', 'failed') | |
| 113 | ORDER BY id DESC`, | |
| 114 | ) | |
| 115 | .all(repoId, branch, WINDOW); | |
| 116 | for (const row of rows) { | |
| 117 | if (!wanted.has(row.commit_oid) || out.has(row.commit_oid)) continue; | |
| 118 | out.set(row.commit_oid, { | |
| 119 | commit: row.commit_oid, | |
| 120 | checkedCommit: row.commit_oid, | |
| 121 | state: row.state as "ok" | "failed", | |
| 122 | runId: row.id, | |
| 123 | summary: row.summary, | |
| 124 | finishedAt: row.finished_at, | |
| 125 | }); | |
| 126 | } | |
| 127 | return out; | |
| 128 | } | |
| 129 | ||
| 90 | 130 | /** How many later commits main checked are asked whether they contain the tag. */ |
| 91 | 131 | export const DESCENDANTS = 10; |
| 92 | 132 |
src/tui/screens/commits.ts +73 −5
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * about having reached the end. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { checkOfCommit, verdictsOfCommits } from "../../actions/runs.ts"; | |
| 10 | import type { CommitCheck } from "../../checks/commit_check.ts"; | |
| 9 | 11 | import type { FileDiff } from "../../git/diff.ts"; |
| 10 | 12 | import type { Commit, RepoSource } from "../../view/source.ts"; |
| 11 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -31,7 +33,34 @@ import { | ||
| 31 | 33 | runAsync, |
| 32 | 34 | } from "./common.ts"; |
| 33 | 35 | import { refPickerScreen } from "./refs.ts"; |
| 34 | import { sourceName, whereRows, whereStrip } from "./where.ts"; | |
| 36 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; | |
| 37 | ||
| 38 | /** A log row's mark: ✓ green, ✗ red, blank where the commit's own check has no verdict. */ | |
| 39 | const markOf = (check: CommitCheck | undefined): string => | |
| 40 | check === undefined ? " " : check.state === "ok" ? "✓" : "✗"; | |
| 41 | ||
| 42 | /** | |
| 43 | * One line for a commit's check: green, red with the failing step, or not | |
| 44 | * run. When a later main commit's check answers (`checkedCommit`), it says | |
| 45 | * whose, so a row the log left unmarked never opens to a bare "green". | |
| 46 | */ | |
| 47 | export function checkLine(check: CommitCheck | null): string { | |
| 48 | if (check === null) return ""; | |
| 49 | const step = check.summary.split("\n")[0]?.trim() ?? ""; | |
| 50 | const via = check.checkedCommit === check.commit ? "" : ` · via ${check.checkedCommit.slice(0, 8)}`; | |
| 51 | switch (check.state) { | |
| 52 | case "ok": | |
| 53 | return `check: green${via}`; | |
| 54 | case "failed": | |
| 55 | return step === "" ? `check: red${via}` : `check: red · ${step}${via}`; | |
| 56 | case "pending": | |
| 57 | return `check: running${via}`; | |
| 58 | case "superseded": | |
| 59 | return "check: superseded"; | |
| 60 | default: | |
| 61 | return "check: not run"; | |
| 62 | } | |
| 63 | } | |
| 35 | 64 | |
| 36 | 65 | export function commitsScreen( |
| 37 | 66 | f: Session, |
| @@ -45,6 +74,8 @@ export function commitsScreen( | ||
| 45 | 74 | /** "No commits" is a claim, and it waits for the first page to answer. */ |
| 46 | 75 | let loaded = false; |
| 47 | 76 | const state = new ListState(); |
| 77 | /** Green or red marks for the log, one query per page; empty where the viewer may not read runs. */ | |
| 78 | const marks = new Map<string, CommitCheck>(); | |
| 48 | 79 | |
| 49 | 80 | const page = async (): Promise<void> => { |
| 50 | 81 | const result = await src.commits(ref, cursor); |
| @@ -53,6 +84,15 @@ export function commitsScreen( | ||
| 53 | 84 | if (commits.length === 0) |
| 54 | 85 | ctx.toast(`no commits on ${ref}: ${errorText(result.error)}`, "danger"); |
| 55 | 86 | } else { |
| 87 | const repo = localRepo(src); | |
| 88 | if (repo !== undefined) | |
| 89 | for (const [oid, check] of verdictsOfCommits( | |
| 90 | f.app, | |
| 91 | f.user, | |
| 92 | repo, | |
| 93 | result.value.items.map((commit) => commit.oid), | |
| 94 | )) | |
| 95 | marks.set(oid, check); | |
| 56 | 96 | commits = [...commits, ...result.value.items]; |
| 57 | 97 | cursor = result.value.next ?? undefined; |
| 58 | 98 | more = result.value.next !== null; |
| @@ -104,7 +144,7 @@ export function commitsScreen( | ||
| 104 | 144 | const rows = commits.map((commit) => ({ |
| 105 | 145 | title: commit.subject, |
| 106 | 146 | secondary: commit.login ?? commit.author, |
| 107 | meta: `${commit.oid.slice(0, 8)} ${relTime(commit.date)}`, | |
| 147 | meta: `${markOf(marks.get(commit.oid))} ${commit.oid.slice(0, 8)} ${relTime(commit.date)}`, | |
| 108 | 148 | })); |
| 109 | 149 | if (more) rows.push({ title: "Load more…", secondary: "", meta: "" }); |
| 110 | 150 | drawList(s, body!, c, state, rows); |
| @@ -127,6 +167,7 @@ export function commitsScreen( | ||
| 127 | 167 | } |
| 128 | 168 | if (e.key === "r") { |
| 129 | 169 | commits = []; |
| 170 | marks.clear(); | |
| 130 | 171 | cursor = undefined; |
| 131 | 172 | more = true; |
| 132 | 173 | loaded = false; |
| @@ -146,12 +187,22 @@ export function commitScreen( | ||
| 146 | 187 | commit: Commit, |
| 147 | 188 | ): Screen { |
| 148 | 189 | const view = new DiffView(); |
| 190 | let check: CommitCheck | null = null; | |
| 149 | 191 | return { |
| 150 | 192 | title: commit.oid.slice(0, 8), |
| 151 | 193 | keys: view.keys, |
| 152 | 194 | async load() { |
| 153 | 195 | const result = await src.commit(commit.oid); |
| 154 | 196 | view.setFiles(result.ok ? result.value.diff.files : []); |
| 197 | const repo = localRepo(src); | |
| 198 | if (repo !== undefined) { | |
| 199 | try { | |
| 200 | const answer = await checkOfCommit(f.app, f.user, repo, commit.oid); | |
| 201 | check = answer.ok ? answer.value : null; | |
| 202 | } catch { | |
| 203 | check = null; // the diff still draws without its check line | |
| 204 | } | |
| 205 | } | |
| 155 | 206 | ctx.invalidate(); |
| 156 | 207 | }, |
| 157 | 208 | render(area, s) { |
| @@ -171,10 +222,27 @@ export function commitScreen( | ||
| 171 | 222 | style(c.fg, c.card, 1), |
| 172 | 223 | inner.x + inner.w, |
| 173 | 224 | ); |
| 174 | if (inner.h > 1) | |
| 225 | const verdict = checkLine(check); | |
| 226 | const at = verdict === "" ? 0 : 1; | |
| 227 | if (verdict !== "" && inner.h > 1) | |
| 175 | 228 | s.text( |
| 176 | 229 | inner.x, |
| 177 | 230 | inner.y + 1, |
| 231 | truncate(verdict, inner.w), | |
| 232 | style( | |
| 233 | check?.state === "ok" | |
| 234 | ? c.success | |
| 235 | : check?.state === "failed" | |
| 236 | ? c.danger | |
| 237 | : c.mutedFg, | |
| 238 | c.card, | |
| 239 | ), | |
| 240 | inner.x + inner.w, | |
| 241 | ); | |
| 242 | if (inner.h > 1 + at) | |
| 243 | s.text( | |
| 244 | inner.x, | |
| 245 | inner.y + 1 + at, | |
| 178 | 246 | truncate( |
| 179 | 247 | `${commit.author} <${commit.email}> · ${relTime(commit.date)} · ${view.summary()}`, |
| 180 | 248 | inner.w, |
| @@ -182,10 +250,10 @@ export function commitScreen( | ||
| 182 | 250 | style(c.mutedFg, c.card), |
| 183 | 251 | inner.x + inner.w, |
| 184 | 252 | ); |
| 185 | if (inner.h > 2 && commit.body !== "") | |
| 253 | if (inner.h > 2 + at && commit.body !== "") | |
| 186 | 254 | s.text( |
| 187 | 255 | inner.x, |
| 188 | inner.y + 2, | |
| 256 | inner.y + 2 + at, | |
| 189 | 257 | truncate(commit.body.split("\n")[0] ?? "", inner.w), |
| 190 | 258 | style(c.faint, c.card), |
| 191 | 259 | inner.x + inner.w, |
test/tui-commit-check.test.ts added +264
| @@ -0,0 +1,264 @@ | ||
| 1 | /** | |
| 2 | * The terminal's history says each commit's check, as MCP `commits` with | |
| 3 | * `oid` does: a mark in the log for green or red, a line under the title of | |
| 4 | * one commit, nothing where no check ran or the viewer may not read runs. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 14 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createRun, finishRun } from "../src/core/runs.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import type { CommitCheck } from "../src/checks/commit_check.ts"; | |
| 18 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 19 | import { checkLine, commitScreen, commitsScreen } from "../src/tui/screens/commits.ts"; | |
| 20 | import { local } from "../src/tui/screens/where.ts"; | |
| 21 | import { Session } from "../src/tui/session.ts"; | |
| 22 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 23 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 24 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | let dir: string; | |
| 30 | let app: App; | |
| 31 | let alice: User; | |
| 32 | let repo: Repo; | |
| 33 | let priv: Repo; | |
| 34 | let bob: User; | |
| 35 | let appRepo: Repo; | |
| 36 | let viaRepo: Repo; | |
| 37 | let viaOids: string[] = []; | |
| 38 | const oids: string[] = []; | |
| 39 | ||
| 40 | const ctx = { | |
| 41 | theme: theme("dark"), | |
| 42 | push: () => {}, | |
| 43 | pop: () => {}, | |
| 44 | replace: () => {}, | |
| 45 | invalidate: () => {}, | |
| 46 | toast: () => {}, | |
| 47 | hit: () => {}, | |
| 48 | track: <T>(p: Promise<T>) => p, | |
| 49 | } as unknown as Ctx; | |
| 50 | ||
| 51 | const draw = (screen: Screen): string => { | |
| 52 | const s = new Surface(110, 30); | |
| 53 | screen.render({ x: 0, y: 0, w: 110, h: 30 }, s); | |
| 54 | return s.toText(); | |
| 55 | }; | |
| 56 | ||
| 57 | const session = (user: User | undefined) => | |
| 58 | new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 59 | ||
| 60 | async function check(r: Repo, oid: string, state: "ok" | "failed", summary: string) { | |
| 61 | const run = createRun(app.db, { | |
| 62 | repoId: r.id, | |
| 63 | kind: "check", | |
| 64 | env: r.defaultBranch, | |
| 65 | commitOid: oid, | |
| 66 | }); | |
| 67 | app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(run.id); | |
| 68 | finishRun(app.db, run.id, { state, summary }); | |
| 69 | } | |
| 70 | ||
| 71 | async function seed(name: string, visibility: "public" | "private") { | |
| 72 | const made = await createRepo(app, alice, { name, visibility }); | |
| 73 | if (!made.ok) throw new Error("repo creation failed"); | |
| 74 | const r = getRepo(app.db, "alice", name)!; | |
| 75 | const list: string[] = []; | |
| 76 | for (const n of ["green", "red", "none"]) { | |
| 77 | const c = await commitToBranch(app, alice, r, { | |
| 78 | message: `${n} change`, | |
| 79 | files: [{ path: `${n}.txt`, content: `${n}\n` }], | |
| 80 | }); | |
| 81 | if (!c.ok) throw new Error("commit failed"); | |
| 82 | const log = await local(session(alice), r).commits(r.defaultBranch); | |
| 83 | if (!log.ok) throw new Error("log failed"); | |
| 84 | list.push(log.value.items[0]!.oid); | |
| 85 | } | |
| 86 | return { r, list }; | |
| 87 | } | |
| 88 | ||
| 89 | beforeAll(async () => { | |
| 90 | dir = await mkdtemp(join(tmpdir(), "forge-tui-commit-check-")); | |
| 91 | app = createApp({ dataDir: dir, port: 0 }); | |
| 92 | const a = await seedAdmin(app, { | |
| 93 | username: "alice", | |
| 94 | email: "[email protected]", | |
| 95 | password: "a-long-passphrase", | |
| 96 | }); | |
| 97 | if (!a.ok) throw new Error("signup failed"); | |
| 98 | alice = a.value; | |
| 99 | const pub = await seed("pub", "public"); | |
| 100 | repo = pub.r; | |
| 101 | oids.push(...pub.list); | |
| 102 | await check(repo, oids[0]!, "ok", ""); | |
| 103 | await check(repo, oids[1]!, "failed", "step typecheck failed"); | |
| 104 | const hidden = await seed("hidden", "private"); | |
| 105 | priv = hidden.r; | |
| 106 | await check(priv, hidden.list[1]!, "failed", "step typecheck failed"); | |
| 107 | oids.push(...hidden.list); | |
| 108 | const b = await createUser(app.db, { | |
| 109 | username: "bob", | |
| 110 | email: "[email protected]", | |
| 111 | password: "a-long-passphrase", | |
| 112 | origin: "seed", | |
| 113 | trusted: true, | |
| 114 | }); | |
| 115 | if (!b.ok) throw new Error("bob failed"); | |
| 116 | bob = b.value; | |
| 117 | // Public to read, but its runs answer only to those who may enter the private app. | |
| 118 | const walled = await seed("walled", "public"); | |
| 119 | appRepo = walled.r; | |
| 120 | await check(appRepo, walled.list[0]!, "ok", ""); | |
| 121 | oids.push(...walled.list); | |
| 122 | const madeApp = createAppRecord(app.db, { repoId: appRepo.id, slug: "walled", access: "private" }); | |
| 123 | if (!madeApp.ok) throw new Error("app failed"); | |
| 124 | // main moved on: the head's check covers the commits before it. | |
| 125 | const via = await seed("via", "public"); | |
| 126 | viaRepo = via.r; | |
| 127 | viaOids = via.list; | |
| 128 | await check(viaRepo, viaOids[2]!, "ok", ""); | |
| 129 | }, 120_000); | |
| 130 | ||
| 131 | afterAll(async () => { | |
| 132 | app.close(); | |
| 133 | await rm(dir, { recursive: true, force: true }); | |
| 134 | }); | |
| 135 | ||
| 136 | const rowOf = (text: string, subject: string): string => | |
| 137 | text.split("\n").find((l) => l.includes(subject)) ?? ""; | |
| 138 | ||
| 139 | describe("the log marks green and red", () => { | |
| 140 | test("a green and a red row carry a mark, a row with none does not", async () => { | |
| 141 | const f = session(alice); | |
| 142 | const screen = commitsScreen(f, ctx, local(f, repo), repo.defaultBranch); | |
| 143 | await screen.load!(new AbortController().signal); | |
| 144 | const text = draw(screen); | |
| 145 | expect(rowOf(text, "green change")).toContain("✓"); | |
| 146 | expect(rowOf(text, "red change")).toContain("✗"); | |
| 147 | expect(rowOf(text, "none change")).not.toMatch(/[✓✗]/); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a viewer who cannot read the repository's runs sees no mark", async () => { | |
| 151 | const f = session(undefined); | |
| 152 | const screen = commitsScreen(f, ctx, local(f, priv), priv.defaultBranch); | |
| 153 | await screen.load!(new AbortController().signal); | |
| 154 | expect(draw(screen)).not.toMatch(/[✓✗]/); | |
| 155 | }); | |
| 156 | }); | |
| 157 | ||
| 158 | describe("a viewer who sees the repository but may not read its runs", () => { | |
| 159 | test("the log has no mark for them, and does for a writer", async () => { | |
| 160 | const mark = async (user: User) => { | |
| 161 | const f = session(user); | |
| 162 | const screen = commitsScreen(f, ctx, local(f, appRepo), appRepo.defaultBranch); | |
| 163 | await screen.load!(new AbortController().signal); | |
| 164 | return draw(screen); | |
| 165 | }; | |
| 166 | expect(rowOf(await mark(alice), "green change")).toContain("✓"); | |
| 167 | const seen = await mark(bob); | |
| 168 | expect(seen).toContain("green change"); | |
| 169 | expect(seen).not.toMatch(/[✓✗]/); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the one-commit view has no check line for them, and does for a writer", async () => { | |
| 173 | const open = async (user: User) => { | |
| 174 | const f = session(user); | |
| 175 | const src = local(f, appRepo); | |
| 176 | const found = await src.commit(oids[6]!); | |
| 177 | if (!found.ok) throw new Error("commit not found"); | |
| 178 | const screen = commitScreen(f, ctx, src, found.value.commit); | |
| 179 | await screen.load!(new AbortController().signal); | |
| 180 | return draw(screen); | |
| 181 | }; | |
| 182 | expect(await open(alice)).toContain("check: green"); | |
| 183 | expect(await open(bob)).not.toContain("check:"); | |
| 184 | }); | |
| 185 | }); | |
| 186 | ||
| 187 | describe("a check that comes from a later commit says so", () => { | |
| 188 | test("the log leaves the row unmarked and the commit line names the commit that answered", async () => { | |
| 189 | const f = session(alice); | |
| 190 | const src = local(f, viaRepo); | |
| 191 | const log = commitsScreen(f, ctx, src, viaRepo.defaultBranch); | |
| 192 | await log.load!(new AbortController().signal); | |
| 193 | expect(rowOf(draw(log), "green change")).not.toMatch(/[✓✗]/); | |
| 194 | const found = await src.commit(viaOids[0]!); | |
| 195 | if (!found.ok) throw new Error("commit not found"); | |
| 196 | const screen = commitScreen(f, ctx, src, found.value.commit); | |
| 197 | await screen.load!(new AbortController().signal); | |
| 198 | expect(draw(screen)).toContain(`check: green · via ${viaOids[2]!.slice(0, 8)}`); | |
| 199 | }); | |
| 200 | }); | |
| 201 | ||
| 202 | describe("a failing look-up does not stop the diff", () => { | |
| 203 | test("the screen is still invalidated for a redraw", async () => { | |
| 204 | const broken = Object.create(app, { | |
| 205 | db: { | |
| 206 | get() { | |
| 207 | throw new Error("db gone"); | |
| 208 | }, | |
| 209 | }, | |
| 210 | }) as App; | |
| 211 | const f = new Session(broken, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }); | |
| 212 | const src = local(session(alice), repo); | |
| 213 | const found = await src.commit(oids[0]!); | |
| 214 | if (!found.ok) throw new Error("commit not found"); | |
| 215 | let drawn = 0; | |
| 216 | const screen = commitScreen(f, { ...ctx, invalidate: () => drawn++ } as Ctx, src, found.value.commit); | |
| 217 | await screen.load!(new AbortController().signal); | |
| 218 | expect(drawn).toBe(1); | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 222 | describe("one commit says its check under its title", () => { | |
| 223 | const open = async (user: User | undefined, r: Repo, oid: string) => { | |
| 224 | const f = session(user); | |
| 225 | const src = local(f, r); | |
| 226 | const found = await src.commit(oid); | |
| 227 | if (!found.ok) throw new Error("commit not found"); | |
| 228 | const screen = commitScreen(f, ctx, src, found.value.commit); | |
| 229 | await screen.load!(new AbortController().signal); | |
| 230 | return draw(screen); | |
| 231 | }; | |
| 232 | ||
| 233 | test("green, red with the failing step, and not run", async () => { | |
| 234 | expect(await open(alice, repo, oids[0]!)).toContain("check: green"); | |
| 235 | expect(await open(alice, repo, oids[1]!)).toContain("check: red · step typecheck failed"); | |
| 236 | expect(await open(alice, repo, oids[2]!)).toContain("check: not run"); | |
| 237 | }); | |
| 238 | ||
| 239 | test("an unreadable repository shows no check", async () => { | |
| 240 | expect(await open(undefined, priv, oids[4]!)).not.toContain("check:"); | |
| 241 | }); | |
| 242 | ||
| 243 | test("checkLine is empty without an answer", () => { | |
| 244 | expect(checkLine(null)).toBe(""); | |
| 245 | }); | |
| 246 | ||
| 247 | test("checkLine words running, superseded, and a red with no summary", () => { | |
| 248 | const base: CommitCheck = { | |
| 249 | commit: "a".repeat(40), | |
| 250 | checkedCommit: "a".repeat(40), | |
| 251 | state: "pending", | |
| 252 | runId: "r", | |
| 253 | summary: "", | |
| 254 | finishedAt: null, | |
| 255 | }; | |
| 256 | expect(checkLine(base)).toBe("check: running"); | |
| 257 | expect(checkLine({ ...base, state: "superseded" })).toBe("check: superseded"); | |
| 258 | expect(checkLine({ ...base, state: "failed" })).toBe("check: red"); | |
| 259 | expect(checkLine({ ...base, state: "none" })).toBe("check: not run"); | |
| 260 | expect(checkLine({ ...base, state: "ok", checkedCommit: "b".repeat(40) })).toBe( | |
| 261 | "check: green · via bbbbbbbb", | |
| 262 | ); | |
| 263 | }); | |
| 264 | }); |
new above
offline