Public: anyone can read this; the crew can change it.
A test pins what a park reason says about each try: the files each red head changed since the one before #6886
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6873-a-test-pins-what-a-park-reason-says-abou → main 1 commit fork
1 file
changed +219
−0
test/crew-tries.test.ts added +219
| @@ -0,0 +1,219 @@ | ||
| 1 | /** | |
| 2 | * What a park reason says about each try (#6873): the files each red head | |
| 3 | * changed since the head before it, the first since the base. Runs are | |
| 4 | * written with explicit instants; the wall clock is never read. | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { trackPullHead, type Pull } from "../src/core/pulls.ts"; | |
| 14 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { resolveOid } from "../src/git/merge.ts"; | |
| 18 | import { repoDir } from "../src/git/repos.ts"; | |
| 19 | import { triesChanged } from "../src/crew/tries.ts"; | |
| 20 | ||
| 21 | const at = (n: number) => `2026-01-01T00:00:${String(n).padStart(2, "0")}.000Z`; | |
| 22 | const EPOCH = at(0); | |
| 23 | ||
| 24 | describe("triesChanged", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let repo: Repo; | |
| 29 | let indexN = 0; | |
| 30 | let branchN = 0; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-tries-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | const a = await createUser(app.db, { | |
| 36 | origin: "seed", | |
| 37 | trusted: true, | |
| 38 | username: "alice", | |
| 39 | email: "[email protected]", | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!a.ok) throw new Error(a.error); | |
| 44 | alice = a.value; | |
| 45 | const made = await createRepo(app, alice, { name: "engine", visibility: "private" }); | |
| 46 | if (!made.ok) throw new Error(String(made.error)); | |
| 47 | repo = made.value; | |
| 48 | const seeded = await commitToBranch(app, alice, fresh(), { | |
| 49 | message: "Start", | |
| 50 | files: [{ path: "README.md", content: "start\n" }], | |
| 51 | }); | |
| 52 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 53 | }); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | app.close(); | |
| 57 | await rm(dataDir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 61 | ||
| 62 | /** A pull whose branch holds one commit, so a pull exists to ask about. */ | |
| 63 | async function newPull(): Promise<{ pull: Pull; branch: string }> { | |
| 64 | const branch = `feature/${++branchN}`; | |
| 65 | const c = await commitToBranch(app, alice, fresh(), { | |
| 66 | branch, | |
| 67 | base: "main", | |
| 68 | message: "First", | |
| 69 | files: [{ path: `first-${branchN}.txt`, content: "1\n" }], | |
| 70 | }); | |
| 71 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 72 | const opened = await openPull(app, alice, fresh(), { | |
| 73 | title: "Tries", | |
| 74 | body: "", | |
| 75 | baseBranch: "main", | |
| 76 | headBranch: branch, | |
| 77 | }); | |
| 78 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 79 | return { pull: opened.value, branch }; | |
| 80 | } | |
| 81 | ||
| 82 | /** Commit on the branch; the new head's oid. */ | |
| 83 | async function commit(branch: string, files: { path: string; content: string }[]) { | |
| 84 | const c = await commitToBranch(app, alice, fresh(), { branch, message: "Try", files }); | |
| 85 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 86 | return c.value.commitOid; | |
| 87 | } | |
| 88 | ||
| 89 | /** A finished check run on `oid`, created at second `n`. */ | |
| 90 | function run(p: Pull, oid: string, state: "ok" | "failed", n: number, kind: "check" | "deploy" = "check") { | |
| 91 | const r = createRun(app.db, { repoId: repo.id, kind, issueId: p.id, commitOid: oid }); | |
| 92 | claimRun(app.db, r.id); | |
| 93 | finishRun(app.db, r.id, { state, summary: state }); | |
| 94 | app.db.query("UPDATE run SET created_at = ?2 WHERE id = ?1").run(r.id, at(n)); | |
| 95 | return r; | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * A commit adding one file whose name the API refuses (a newline), made | |
| 100 | * with git directly on top of the branch tip, the way a push could. | |
| 101 | */ | |
| 102 | async function pushed(branch: string, path: string): Promise<string> { | |
| 103 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 104 | const index = join(dataDir, `index-${++indexN}`); | |
| 105 | const git = async (args: string[], stdin?: string) => { | |
| 106 | const proc = Bun.spawn(["git", ...args], { | |
| 107 | cwd: bare, | |
| 108 | stdin: stdin === undefined ? "ignore" : new Blob([stdin]), | |
| 109 | stdout: "pipe", | |
| 110 | stderr: "pipe", | |
| 111 | env: { | |
| 112 | ...process.env, | |
| 113 | GIT_INDEX_FILE: index, | |
| 114 | GIT_AUTHOR_NAME: "t", | |
| 115 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 116 | GIT_COMMITTER_NAME: "t", | |
| 117 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 118 | }, | |
| 119 | }); | |
| 120 | const out = await new Response(proc.stdout).text(); | |
| 121 | expect(await proc.exited).toBe(0); | |
| 122 | return out.trim(); | |
| 123 | }; | |
| 124 | const tip = (await resolveOid(bare, `refs/heads/${branch}`))!; | |
| 125 | await git(["read-tree", tip]); | |
| 126 | const blob = await git(["hash-object", "-w", "--stdin"], "x\n"); | |
| 127 | await git(["update-index", "--add", "--cacheinfo", `100644,${blob},${path}`]); | |
| 128 | const tree = await git(["write-tree"]); | |
| 129 | const oid = await git(["commit-tree", tree, "-p", tip, "-m", "Odd"]); | |
| 130 | await git(["update-ref", `refs/heads/${branch}`, oid]); | |
| 131 | trackPullHead(app.db, repo.id, branch, oid); | |
| 132 | return oid; | |
| 133 | } | |
| 134 | ||
| 135 | test("tells each red head's files since the head before it, the first since the base", async () => { | |
| 136 | const { pull: p, branch } = await newPull(); | |
| 137 | const h1 = await commit(branch, [{ path: "src/a.ts", content: "a\n" }]); | |
| 138 | const h2 = await commit(branch, [{ path: "src/b.ts", content: "b\n" }]); | |
| 139 | const h3 = await commit(branch, [{ path: "src/a.ts", content: "a2\n" }, { path: "src/c.ts", content: "c\n" }]); | |
| 140 | run(p, h1, "failed", 1); | |
| 141 | run(p, h2, "failed", 2, "deploy"); | |
| 142 | run(p, h3, "failed", 3); | |
| 143 | const all = await triesChanged(app, fresh(), p, EPOCH); | |
| 144 | // Only the last TRIES_TOLD (2) lines come back, numbered by their place. | |
| 145 | expect(all).toEqual(["try 2: src/b.ts", "try 3: src/a.ts, src/c.ts"]); | |
| 146 | ||
| 147 | // With one try the first line says the files since the base. | |
| 148 | const { pull: q, branch: qb } = await newPull(); | |
| 149 | const g1 = await commit(qb, [{ path: "src/x.ts", content: "x\n" }]); | |
| 150 | run(q, g1, "failed", 1); | |
| 151 | expect(await triesChanged(app, fresh(), q, EPOCH)).toEqual([ | |
| 152 | `try 1: first-${branchN}.txt, src/x.ts`, | |
| 153 | ]); | |
| 154 | }); | |
| 155 | ||
| 156 | test("skips a green run, a run before since, a repeated head and a non-oid head", async () => { | |
| 157 | const { pull: p, branch } = await newPull(); | |
| 158 | const old = await commit(branch, [{ path: "old.ts", content: "o\n" }]); | |
| 159 | const green = await commit(branch, [{ path: "green.ts", content: "g\n" }]); | |
| 160 | const red = await commit(branch, [{ path: "red.ts", content: "r\n" }]); | |
| 161 | run(p, old, "failed", 1); | |
| 162 | run(p, green, "ok", 5); | |
| 163 | run(p, red, "failed", 6); | |
| 164 | run(p, red, "failed", 7); | |
| 165 | run(p, "not-an-oid", "failed", 8); | |
| 166 | run(p, "", "failed", 9); | |
| 167 | expect(await triesChanged(app, fresh(), p, at(2))).toEqual([`try 1: first-${branchN}.txt, green.ts, old.ts, red.ts`]); | |
| 168 | expect(await triesChanged(app, fresh(), p, at(9))).toEqual([]); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a head that changed nothing says so", async () => { | |
| 172 | const { pull: p, branch } = await newPull(); | |
| 173 | const h1 = await commit(branch, [{ path: "same.ts", content: "s\n" }]); | |
| 174 | // The same tree again: a new commit with no file changed. | |
| 175 | const h2 = await commit(branch, [{ path: "same.ts", content: "s\n" }]); | |
| 176 | expect(h2).not.toBe(h1); | |
| 177 | run(p, h1, "failed", 1); | |
| 178 | run(p, h2, "failed", 2); | |
| 179 | expect(await triesChanged(app, fresh(), p, EPOCH)).toEqual([ | |
| 180 | `try 1: first-${branchN}.txt, same.ts`, | |
| 181 | "try 2: no files changed", | |
| 182 | ]); | |
| 183 | }); | |
| 184 | ||
| 185 | test("more files than the limit end in a count; a long line is cut with an ellipsis", async () => { | |
| 186 | const { pull: p, branch } = await newPull(); | |
| 187 | const many = await commit( | |
| 188 | branch, | |
| 189 | ["a", "b", "c", "d", "e", "f"].map((n) => ({ path: `m/${n}.ts`, content: `${n}\n` })), | |
| 190 | ); | |
| 191 | run(p, many, "failed", 1); | |
| 192 | expect(await triesChanged(app, fresh(), p, EPOCH)).toEqual([ | |
| 193 | `try 1: first-${branchN}.txt, m/a.ts, m/b.ts, m/c.ts (+3 more)`, | |
| 194 | ]); | |
| 195 | ||
| 196 | const long = "d".repeat(100); | |
| 197 | const wide = await commit(branch, [ | |
| 198 | { path: `${long}/one.ts`, content: "1\n" }, | |
| 199 | { path: `${long}/two.ts`, content: "2\n" }, | |
| 200 | ]); | |
| 201 | run(p, wide, "failed", 2); | |
| 202 | const lines = await triesChanged(app, fresh(), p, EPOCH); | |
| 203 | const cut = lines[1]!; | |
| 204 | expect(cut.length).toBe(160); | |
| 205 | expect(cut.endsWith("…")).toBe(true); | |
| 206 | expect(cut.startsWith(`try 2: ${long}/one.ts, `)).toBe(true); | |
| 207 | }); | |
| 208 | ||
| 209 | test("a name with a newline or markup comes back as git gave it, as text", async () => { | |
| 210 | const { pull: p, branch } = await newPull(); | |
| 211 | const odd = "we\nird/<img src=x onerror=alert(1)>.ts"; | |
| 212 | const h = await pushed(branch, odd); | |
| 213 | run(p, h, "failed", 1); | |
| 214 | const lines = await triesChanged(app, fresh(), p, EPOCH); | |
| 215 | expect(lines).toEqual([`try 1: first-${branchN}.txt, ${odd}`]); | |
| 216 | expect(lines[0]).toContain("<img src=x onerror=alert(1)>"); | |
| 217 | expect(lines[0]).not.toContain("<"); | |
| 218 | }); | |
| 219 | }); |
new above
offline