Public: anyone can read this; the crew can change it.
The full suite runs as a scheduled report that files what it finds and never blocks a landing #8247
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7780-the-full-suite-runs-as-a-scheduled-repor → main 7 commits fork 4 threads · 4 unresolved
11 files
changed +367
−25
README.md +1 −1
| @@ -410,7 +410,7 @@ work here. | ||
| 410 | 410 | |
| 411 | 411 | ## What works today |
| 412 | 412 | |
| 413 | Every pull says which of four steps it is at — check, review, land, live — and why it waits there: a line under its title on both tabs (linked to `/pipeline`, a public page that explains the four steps), `step: { step, why, done }` in the MCP `pull` tool, and `step: …` in the terminal pull screen's header; the same word and sentence sit on the pull's post in the feed, its row on the pulls list and on `/review` (each linked to `/pipeline`), and in the terminal's feed, pulls list and on-you list (`Review — No review yet.` under the row). See [](docs/features/pipeline.md). | |
| 413 | Every pull says which of four steps it is at — check, review, land, live — and why it waits there: a line under its title on both tabs (linked to `/pipeline`, a public page that explains the four steps), `step: { step, why, done }` in the MCP `pull` tool, and `step: …` in the terminal pull screen's header; the same word and sentence sit on the pull's post in the feed, its row on the pulls list and on `/review` (each linked to `/pipeline`), and in the terminal's feed, pulls list and on-you list (`Review — No review yet.` under the row). The whole suite is a report, not a gate: after a landing it runs against the default branch and each failing test file gets one issue labelled `full-suite` (listed from `/pipeline`; the terminal's Flow dialog counts the open ones and the issue list filters by label with `l`). See [](docs/features/pipeline.md). | |
| 414 | 414 | |
| 415 | 415 | A public app of a public repository tells an agent that lands on it what it can |
| 416 | 416 | do: its HTML pages, and the "not running" page, carry two `Link` headers — |
docs/features/pipeline.md +21 −2
| @@ -45,8 +45,27 @@ The step is looked up per row (a few queries each); batching it is left for when | ||
| 45 | 45 | the lists measure slow. |
| 46 | 46 | `changeHead` computes "who acts next" once and hands it to `stepOfPull`. |
| 47 | 47 | |
| 48 | ## The full suite as a report | |
| 49 | ||
| 50 | A verification after landing (`src/checks/verify.ts`) runs every shard against | |
| 51 | the default branch's tip. When it is red, `src/checks/report.ts` files one | |
| 52 | issue per failing test file through `fileIssueOnce` (`src/checks/flake.ts`), | |
| 53 | titled `Full suite red: <file>` and labelled `full-suite`; a later red adds a | |
| 54 | comment to the open issue instead of filing again. Nothing that decides a | |
| 55 | pull, a batch or a release reads a verification, so the report holds none. | |
| 56 | ||
| 57 | How it is scheduled: the checks service looks every minute (`VERIFY_SWEEP_MS`) | |
| 58 | for a repository whose default branch has moved since its last verification | |
| 59 | and verifies it, at most once per `VERIFY_EVERY_MS` (30 minutes, set in | |
| 60 | `src/main.ts`) and never two at once. So the report runs after a landing, not | |
| 61 | on a daily clock: a quiet main is not re-run, a busy one at most every half hour. | |
| 62 | ||
| 63 | `/pipeline` links the label (in the forge's own repository, `ownRepoPath()`) | |
| 64 | in the card under "Where to look next". The terminal's Flow dialog ends with | |
| 65 | a line counting the open report issues; the issue list there filters by | |
| 66 | label with `l`. | |
| 67 | ||
| 48 | 68 | ## Not here yet |
| 49 | 69 | |
| 50 | The rest of #7703 is filed as its own issues: remove the proof (#7779), run | |
| 51 | the full suite as a scheduled report (#7780), re-run a shard that crashed | |
| 70 | The rest of #7703 is filed as its own issues: remove the proof (#7779), re-run a shard that crashed | |
| 52 | 71 | outside any test (#7781), reuse a verdict for the same tree (#7782), and measure each step (#7784). |
src/checks/flake.ts +52 −20
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | import { createIssue } from "../core/issues.ts"; | |
| 13 | import { createIssue, setIssueLabels } from "../core/issues.ts"; | |
| 14 | 14 | import type { Repo } from "../core/repos.ts"; |
| 15 | 15 | import type { EventBus } from "../core/events.ts"; |
| 16 | 16 | import { id } from "../kernel/id.ts"; |
| @@ -241,35 +241,67 @@ export function recordFlakes( | ||
| 241 | 241 | .get(repo.id, t.name, since)!.n; |
| 242 | 242 | // No issue without a file: the "Files:" line is what routes it to a builder. |
| 243 | 243 | if (count < FILE_AT || t.file === "") continue; |
| 244 | const title = flakeTitle(t.name); | |
| 245 | const open = db | |
| 246 | .query<{ id: string }, [string, string]>( | |
| 247 | "SELECT id FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open' LIMIT 1", | |
| 248 | ) | |
| 249 | .get(repo.id, title); | |
| 250 | if (open !== null) continue; | |
| 251 | const made = createIssue(db, { | |
| 252 | repoId: repo.id, | |
| 253 | authorId: repo.ownerId, | |
| 254 | title, | |
| 244 | const filed = fileIssueOnce(db, bus, repo, { | |
| 245 | title: flakeTitle(t.name), | |
| 255 | 246 | body: |
| 256 | 247 | `_Filed by the check runner from a check log, not written by a person._\n\n` + |
| 257 | 248 | `\`${t.name}\` in \`${t.file}\` failed in ${where} and passed when run alone, ${count} times in 7 days ` + |
| 258 | 249 | `(latest: check run ${ref.runId}, ${ref.commit.slice(0, 7)}). The check stayed green each time. ` + |
| 259 | 250 | `Find what it depends on: time, load, order, or shared state.\n\nFiles: ${t.file}`, |
| 260 | 251 | }); |
| 261 | if (!made.ok) continue; | |
| 262 | bus.append({ | |
| 263 | kind: "issue.opened", | |
| 264 | repoId: repo.id, | |
| 265 | actorId: repo.ownerId, | |
| 266 | payload: { number: made.value.number }, | |
| 267 | }); | |
| 268 | opened.push(made.value.number); | |
| 252 | if (filed.opened) opened.push(filed.number); | |
| 269 | 253 | } |
| 270 | 254 | return opened; |
| 271 | 255 | } |
| 272 | 256 | |
| 257 | /** | |
| 258 | * One open issue per title: the open one when there is one (`opened` false), | |
| 259 | * else a new one, announced on the bus. Number 0 when it could not be made. | |
| 260 | */ | |
| 261 | export function fileIssueOnce( | |
| 262 | db: Database, | |
| 263 | bus: EventBus, | |
| 264 | repo: Pick<Repo, "id" | "ownerId">, | |
| 265 | issue: { title: string; body: string; labelId?: string }, | |
| 266 | ): { number: number; opened: boolean } { | |
| 267 | const open = db | |
| 268 | .query<{ number: number }, [string, string]>( | |
| 269 | "SELECT number FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open' LIMIT 1", | |
| 270 | ) | |
| 271 | .get(repo.id, issue.title); | |
| 272 | if (open !== null) return { number: open.number, opened: false }; | |
| 273 | const made = createIssue(db, { repoId: repo.id, authorId: repo.ownerId, title: issue.title, body: issue.body }); | |
| 274 | if (!made.ok) return { number: 0, opened: false }; | |
| 275 | if (issue.labelId !== undefined) setIssueLabels(db, made.value.id, [issue.labelId]); | |
| 276 | bus.append({ | |
| 277 | kind: "issue.opened", | |
| 278 | repoId: repo.id, | |
| 279 | actorId: repo.ownerId, | |
| 280 | payload: { number: made.value.number }, | |
| 281 | }); | |
| 282 | return { number: made.value.number, opened: true }; | |
| 283 | } | |
| 284 | ||
| 285 | /** Every failing test of a `bun test` log with the file whose report printed it, once each, in order. */ | |
| 286 | export function failedTests(lines: readonly string[]): Flaky[] { | |
| 287 | const out: Flaky[] = []; | |
| 288 | const seen = new Set<string>(); | |
| 289 | let file: string | undefined; | |
| 290 | for (const line of lines) { | |
| 291 | const header = HEADER.exec(line); | |
| 292 | if (header) { | |
| 293 | file = header[1]!.replace(/^\.\//, ""); | |
| 294 | continue; | |
| 295 | } | |
| 296 | const name = FAIL.exec(line)?.[1]; | |
| 297 | if (name === undefined || file === undefined || !PATH.test(file)) continue; | |
| 298 | if (name.length > NAME_MAX || CONTROL.test(name) || seen.has(`${file}\0${name}`)) continue; | |
| 299 | seen.add(`${file}\0${name}`); | |
| 300 | out.push({ file, name }); | |
| 301 | } | |
| 302 | return out; | |
| 303 | } | |
| 304 | ||
| 273 | 305 | /** What a red queue verdict says of its tests: each failing one with its file, and the files it learned. */ |
| 274 | 306 | export interface QueueRed { |
| 275 | 307 | readonly tests?: { readonly failing: readonly { readonly name: string; readonly file?: string }[] }; |
src/checks/report.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** | |
| 2 | * The full suite as a scheduled report (#7780). A verification after landing | |
| 3 | * (`checks/verify.ts`) runs every shard against main's tip; when it is red, | |
| 4 | * each failing test file gets one issue, labelled {@link REPORT_LABEL} so it | |
| 5 | * is found: the open one is added to when a later run fails the same file, | |
| 6 | * and none is made twice. The report only files. Nothing reads a verification | |
| 7 | * when it decides whether a pull, a batch or a release may go ahead (those | |
| 8 | * look at the default branch's own check), so a red report holds none of them. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { addComment, createLabel, getLabel, listIssues, type Label } from "../core/issues.ts"; | |
| 14 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 15 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 16 | import type { CheckDetail, Run } from "../core/runs.ts"; | |
| 17 | import { failedTests, fileIssueOnce } from "./flake.ts"; | |
| 18 | ||
| 19 | /** The label every report issue carries. */ | |
| 20 | export const REPORT_LABEL = "full-suite"; | |
| 21 | const REPORT_COLOR = "d93f0b"; | |
| 22 | /** The most failing tests a run carries for the report. */ | |
| 23 | export const MAX_FAILURES = 40; | |
| 24 | /** The most tests one issue names. */ | |
| 25 | const NAMED = 10; | |
| 26 | ||
| 27 | export const reportTitle = (file: string): string => `Full suite red: ${file}`; | |
| 28 | ||
| 29 | /** A red verification's detail with the failing tests under its failing files (at most {@link MAX_FAILURES}). */ | |
| 30 | export function redDetail( | |
| 31 | detail: CheckDetail | undefined, | |
| 32 | files: readonly string[], | |
| 33 | lines: readonly string[], | |
| 34 | ): CheckDetail | undefined { | |
| 35 | if (files.length === 0) return detail; | |
| 36 | return { | |
| 37 | ...(detail ?? { passed: 0, failed: files.length, names: [] }), | |
| 38 | files, | |
| 39 | failures: failedTests(lines).filter((t) => files.includes(t.file)).slice(0, MAX_FAILURES), | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | function labelOf(app: App, repo: Repo): Label | undefined { | |
| 44 | const made = createLabel(app.db, repo.id, REPORT_LABEL, REPORT_COLOR); | |
| 45 | return made.ok ? made.value : getLabel(app.db, repo.id, REPORT_LABEL); | |
| 46 | } | |
| 47 | ||
| 48 | /** The lines for a red verification's log: one per failing file, filed or added to. Empty when green. */ | |
| 49 | export function reportRed(app: App, repo: Repo, run: Run): string[] { | |
| 50 | if (run.state !== "failed") return []; | |
| 51 | const files = run.checkDetail?.files ?? []; | |
| 52 | const failures = run.checkDetail?.failures ?? []; | |
| 53 | const label = files.length > 0 ? labelOf(app, repo) : undefined; | |
| 54 | const lines: string[] = []; | |
| 55 | for (const file of files) { | |
| 56 | const names = failures.filter((f) => f.file === file).map((f) => f.name); | |
| 57 | const said = names.length === 0 ? "" : `\n\n${names.slice(0, NAMED).map((n) => `- \`${n}\``).join("\n")}`; | |
| 58 | const more = names.length > NAMED ? `\n- and ${names.length - NAMED} more` : ""; | |
| 59 | const where = `check run ${run.id}, ${run.commitOid.slice(0, 7)}`; | |
| 60 | const title = reportTitle(file); | |
| 61 | const filed = fileIssueOnce(app.db, app.bus, repo, { | |
| 62 | title, | |
| 63 | body: | |
| 64 | `_Filed by the scheduled full-suite report, not written by a person. It holds no pull, batch or release._\n\n` + | |
| 65 | `\`${file}\` failed in the full suite on ${repo.defaultBranch} (${where}).` + | |
| 66 | `${said}${more}\n\nFiles: ${file}`, | |
| 67 | ...(label === undefined ? {} : { labelId: label.id }), | |
| 68 | }); | |
| 69 | if (filed.number === 0) lines.push(`report: ${file} not filed`); | |
| 70 | else if (filed.opened) lines.push(`report: filed #${filed.number} for ${file}`); | |
| 71 | else { | |
| 72 | const open = app.db | |
| 73 | .query<{ id: string }, [string, number]>("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2") | |
| 74 | .get(repo.id, filed.number); | |
| 75 | if (open !== null) | |
| 76 | addComment(app.db, { | |
| 77 | issueId: open.id, | |
| 78 | authorId: repo.ownerId, | |
| 79 | body: `Still red in the full suite (${where}).${said}${more}`, | |
| 80 | }); | |
| 81 | lines.push(`report: added to #${filed.number} for ${file}`); | |
| 82 | } | |
| 83 | } | |
| 84 | return lines; | |
| 85 | } | |
| 86 | ||
| 87 | /** Where the report's issues are listed: the forge's own repository, filtered by the label. */ | |
| 88 | export const reportPath = (): string => `/${ownRepoPath()}/issues?label=${REPORT_LABEL}`; | |
| 89 | ||
| 90 | /** One line for the terminal: how many report issues are open and where they are listed. */ | |
| 91 | export function reportLine(db: Database): string { | |
| 92 | const [owner = "", name = ""] = ownRepoPath().split("/"); | |
| 93 | const repo = getRepo(db, owner, name); | |
| 94 | const open = repo === undefined ? 0 : listIssues(db, repo.id, { label: REPORT_LABEL }).length; | |
| 95 | return `Full suite report: ${open} open issue${open === 1 ? "" : "s"} labelled ${REPORT_LABEL} in ${owner}/${name} (the terminal's issue list, key l).`; | |
| 96 | } |
src/checks/service.ts +4 −1
| @@ -64,6 +64,7 @@ import { proposeRevert } from "./revert_red.ts"; | ||
| 64 | 64 | import { rebasesFirst } from "./finishing_first.ts"; |
| 65 | 65 | import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; |
| 66 | 66 | import { afterVerify } from "./culprit.ts"; |
| 67 | import { redDetail, reportRed } from "./report.ts"; | |
| 67 | 68 | import { |
| 68 | 69 | bisectOrigin, |
| 69 | 70 | failedFiles, |
| @@ -858,6 +859,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 858 | 859 | log.error("revert proposal failed", { error: String(error) }), |
| 859 | 860 | ); |
| 860 | 861 | // A red verification after landing names its culprit (#7786). |
| 862 | if (done && run.env === VERIFY_ENV) | |
| 863 | reportRed(app, repo, done).forEach(write); | |
| 861 | 864 | if (done && inVerifyLane(run.env)) |
| 862 | 865 | await afterVerify(app, repo, done, (r, env, oid, reason) => request(r, env, oid, reason)) |
| 863 | 866 | .then((lines) => lines.forEach(write)) |
| @@ -1396,7 +1399,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1396 | 1399 | return { |
| 1397 | 1400 | ok: false, |
| 1398 | 1401 | error: killed ? ran.error : summary, |
| 1399 | detail: files.length > 0 ? { ...(detail ?? { passed: 0, failed: files.length, names: [] }), files } : detail, | |
| 1402 | detail: redDetail(detail, files, checkLines), | |
| 1400 | 1403 | stage: checkStage(ran.error), |
| 1401 | 1404 | ...(killed ? { killed } : {}), |
| 1402 | 1405 | }; |
src/core/runs.ts +2
| @@ -47,6 +47,8 @@ export interface CheckDetail { | ||
| 47 | 47 | readonly names: readonly string[]; |
| 48 | 48 | /** The test files the failures printed under, when a verification after landing failed (#7786). */ |
| 49 | 49 | readonly files?: readonly string[]; |
| 50 | /** The failing tests under those files, at most forty, for the report that files them (#7780). */ | |
| 51 | readonly failures?: readonly { readonly file: string; readonly name: string }[]; | |
| 50 | 52 | /** The check machine's free disk share (0..1) when a red finished (#5453). */ |
| 51 | 53 | readonly diskFree?: number; |
| 52 | 54 | } |
src/tui/screens/flow.ts +6 −1
| @@ -13,12 +13,15 @@ | ||
| 13 | 13 | * changes requested waiting on a builder and on a reviewer (`flow/requested.ts`, #2172); then each |
| 14 | 14 | * autonomous repository's ready list, startable beside the count per |
| 15 | 15 | * filter that holds the rest (`flow/held.ts`, #2244), as the flow panel draws it; |
| 16 | * last, how long each main has gone unverified after landing (`flow/verified.ts`, #7786). | |
| 16 | * then how long each main has gone unverified after landing (`flow/verified.ts`, #7786); | |
| 17 | * then one line for the full-suite report's open issues (`checks/report.ts`, #7780); | |
| 18 | * last, the duplicate lines (`duplicateLines`). | |
| 17 | 19 | * Read-only; Enter, Esc or q closes it. |
| 18 | 20 | */ |
| 19 | 21 | |
| 20 | 22 | import type { Database } from "bun:sqlite"; |
| 21 | 23 | import { recentGates, type KeptResult } from "../../checks/gate_result.ts"; |
| 24 | import { reportLine } from "../../checks/report.ts"; | |
| 22 | 25 | import { HOLDS } from "../../crew/held.ts"; |
| 23 | 26 | import { pullCounts } from "../../crew/hot_files.ts"; |
| 24 | 27 | import { warmingLine } from "../../crew/hot_history.ts"; |
| @@ -139,6 +142,8 @@ export function flowLines( | ||
| 139 | 142 | ? [] |
| 140 | 143 | : ["", ...verified.flatMap((r) => wrap(`${r.repo}: ${verifiedSays(r, now.getTime())}`, inner))]), |
| 141 | 144 | "", |
| 145 | ...wrap(reportLine(db), inner), | |
| 146 | "", | |
| 142 | 147 | ...duplicateLines(dups, inner), |
| 143 | 148 | ]; |
| 144 | 149 | } |
src/web/pages/pipeline.ts +6
| @@ -12,6 +12,7 @@ import { cachedStepStats, statParts, STEP_WINDOW_DAYS, type StepStat } from "../ | ||
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import { html } from "../html.ts"; |
| 14 | 14 | import { page } from "../layout.ts"; |
| 15 | import { reportPath } from "../../checks/report.ts"; | |
| 15 | 16 | import { card, row, stat } from "../ui.ts"; |
| 16 | 17 | |
| 17 | 18 | /** What a pull waits on at each step, what only that step catches, and the parts inside it. */ |
| @@ -110,6 +111,11 @@ export function pipelinePage(ctx: Ctx): Response { | ||
| 110 | 111 | and the rest of the path runs lint and types. An admin sees how many changes sit at each step right now on |
| 111 | 112 | <a href="/boards">Boards</a>. |
| 112 | 113 | </p> |
| 114 | <p class="small" id="full-suite"> | |
| 115 | The whole suite still runs, as a report: after a landing, on a schedule, against the default branch's tip. | |
| 116 | Each test file it finds failing gets one issue labelled <span class="mono">full-suite</span>, listed | |
| 117 | <a href="${reportPath()}">here</a>. The report holds no pull, batch or release. | |
| 118 | </p> | |
| 113 | 119 | <p class="muted small">For agents: the MCP tool <span class="mono">pull</span> answers <span class="mono">step: { step, why, done }</span> with these four words.</p> |
| 114 | 120 | </div>`, |
| 115 | 121 | { head: html`<h2>Where to look next</h2>` }, |
test/check-verify.test.ts +8
| @@ -98,6 +98,14 @@ test("a red verification that fails alone too names the push that broke it in on | ||
| 98 | 98 | const run = await until(() => finished(VERIFY_ENV, oid)); |
| 99 | 99 | expect(run.state).toBe("failed"); |
| 100 | 100 | expect(getRun(app.db, run.id)?.checkDetail?.files).toEqual(["t/a.test.ts"]); |
| 101 | // The service filled the failures and filed the report for the failing file. | |
| 102 | expect(getRun(app.db, run.id)?.checkDetail?.failures).toEqual([{ file: "t/a.test.ts", name: "grp > works" }]); | |
| 103 | const reported = await until(() => | |
| 104 | app.db | |
| 105 | .query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 AND title = 'Full suite red: t/a.test.ts'") | |
| 106 | .get(repo.id) ?? undefined, | |
| 107 | ); | |
| 108 | expect(reported.body).toContain("grp > works"); | |
| 101 | 109 | const issue = await until(() => |
| 102 | 110 | app.db |
| 103 | 111 | .query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND title LIKE 'Red after landing:%'") |
test/full-suite-report.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * The full suite as a scheduled report (#7780): a red verification files one | |
| 3 | * labelled issue per failing file, a rerun adds to it instead of filing | |
| 4 | * again, and the red holds nothing. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterEach, beforeEach, 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 { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 14 | import { MAX_FAILURES, redDetail, reportRed, reportTitle, REPORT_LABEL } from "../src/checks/report.ts"; | |
| 15 | import { failedTests } from "../src/checks/flake.ts"; | |
| 16 | import { heldByRed, redMain } from "../src/checks/red_main.ts"; | |
| 17 | import { VERIFY_ENV } from "../src/checks/verify.ts"; | |
| 18 | import { listIssues, listComments, labelsFor } from "../src/core/issues.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createRun, getRun, type Run } from "../src/core/runs.ts"; | |
| 21 | import { createUser } from "../src/core/users.ts"; | |
| 22 | ||
| 23 | describe("failedTests", () => { | |
| 24 | test("pairs each failure with the file that printed it, once", () => { | |
| 25 | const lines = [ | |
| 26 | "test/a.test.ts:", | |
| 27 | "(pass) ok", | |
| 28 | "(fail) a > one [3.00ms]", | |
| 29 | "(fail) a > one [3.00ms]", | |
| 30 | "test/b.test.ts:", | |
| 31 | "(fail) b > two", | |
| 32 | ]; | |
| 33 | expect(failedTests(lines)).toEqual([ | |
| 34 | { file: "test/a.test.ts", name: "a > one" }, | |
| 35 | { file: "test/b.test.ts", name: "b > two" }, | |
| 36 | ]); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("the report", () => { | |
| 41 | let dataDir: string; | |
| 42 | let app: App; | |
| 43 | let repo: Repo; | |
| 44 | ||
| 45 | beforeEach(async () => { | |
| 46 | dataDir = await mkdtemp(join(tmpdir(), "forge-report-")); | |
| 47 | app = createApp({ dataDir, port: 0 }); | |
| 48 | const created = await createUser(app.db, { | |
| 49 | origin: "seed", | |
| 50 | trusted: true, | |
| 51 | username: "alice", | |
| 52 | email: "[email protected]", | |
| 53 | password: "a-good-passphrase", | |
| 54 | adminIfFirst: true, | |
| 55 | }); | |
| 56 | if (!created.ok) throw new Error(created.error); | |
| 57 | const made = await createRepo(app, created.value, { name: "app", visibility: "public" }); | |
| 58 | if (!made.ok) throw new Error(String(made.error)); | |
| 59 | repo = made.value; | |
| 60 | }); | |
| 61 | ||
| 62 | afterEach(async () => { | |
| 63 | app.close(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | const red = (env: string, commit: string): Run => { | |
| 68 | const run = createRun(app.db, { repoId: repo.id, kind: "check", env, commitOid: commit, reason: "schedule" }); | |
| 69 | const detail = { | |
| 70 | passed: 10, | |
| 71 | failed: 3, | |
| 72 | names: ["a > one", "a > two", "b > three"], | |
| 73 | files: ["test/a.test.ts", "test/b.test.ts"], | |
| 74 | failures: [ | |
| 75 | { file: "test/a.test.ts", name: "a > one" }, | |
| 76 | { file: "test/a.test.ts", name: "a > two" }, | |
| 77 | { file: "test/b.test.ts", name: "b > three" }, | |
| 78 | ], | |
| 79 | }; | |
| 80 | app.db | |
| 81 | .query("UPDATE run SET state = 'failed', check_detail = ?2, finished_at = ?3 WHERE id = ?1") | |
| 82 | .run(run.id, JSON.stringify(detail), new Date().toISOString()); | |
| 83 | return getRun(app.db, run.id)!; | |
| 84 | }; | |
| 85 | ||
| 86 | const reports = () => listIssues(app.db, repo.id, { label: REPORT_LABEL, state: "all" }); | |
| 87 | ||
| 88 | test("one labelled issue per failing file, naming its failing tests", () => { | |
| 89 | const lines = reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 90 | expect(lines).toHaveLength(2); | |
| 91 | const issues = reports(); | |
| 92 | expect(issues.map((i) => i.title).sort()).toEqual([ | |
| 93 | reportTitle("test/a.test.ts"), | |
| 94 | reportTitle("test/b.test.ts"), | |
| 95 | ]); | |
| 96 | const a = issues.find((i) => i.title === reportTitle("test/a.test.ts"))!; | |
| 97 | expect(a.body).toContain("a > one"); | |
| 98 | expect(a.body).toContain("a > two"); | |
| 99 | expect(a.body).not.toContain("b > three"); | |
| 100 | expect(a.body).toContain("Files: test/a.test.ts"); | |
| 101 | expect(labelsFor(app.db, a.id).map((l) => l.name)).toEqual([REPORT_LABEL]); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a rerun files nothing new: it adds to the open issues", () => { | |
| 105 | reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 106 | const again = reportRed(app, repo, red(VERIFY_ENV, "b".repeat(40))); | |
| 107 | expect(again.every((l) => l.startsWith("report: added to #"))).toBe(true); | |
| 108 | expect(reports()).toHaveLength(2); | |
| 109 | const a = reports().find((i) => i.title === reportTitle("test/a.test.ts"))!; | |
| 110 | expect(listComments(app.db, a.id)).toHaveLength(1); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a closed issue is filed anew when the file fails again", () => { | |
| 114 | reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 115 | app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1").run(repo.id); | |
| 116 | reportRed(app, repo, red(VERIFY_ENV, "b".repeat(40))); | |
| 117 | expect(reports()).toHaveLength(4); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a green verification files nothing", () => { | |
| 121 | const run = createRun(app.db, { repoId: repo.id, kind: "check", env: VERIFY_ENV, commitOid: "c".repeat(40) }); | |
| 122 | app.db.query("UPDATE run SET state = 'ok' WHERE id = ?1").run(run.id); | |
| 123 | expect(reportRed(app, repo, getRun(app.db, run.id)!)).toEqual([]); | |
| 124 | expect(reports()).toHaveLength(0); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a red report holds nothing: main is not red, and a pull's check is not held", () => { | |
| 128 | reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 129 | const main = redMain(app.db, repo.id); | |
| 130 | expect(main).toBeUndefined(); | |
| 131 | const check = { kind: "check" as const, issueId: "x", reason: "", createdAt: new Date().toISOString() }; | |
| 132 | expect(heldByRed(check, main, 20 * 60_000, Date.now())).toBe(false); | |
| 133 | }); | |
| 134 | ||
outdated
Tautology: it asserts a constant equals itself. Test the cap on real input. |
||
| 135 | test("a run carries at most forty failures, only under its failing files", () => { | |
| 136 | const lines = ["test/a.test.ts:", ...Array.from({ length: 60 }, (_, i) => `(fail) a > t${i}`), "test/c.test.ts:", "(fail) c > x"]; | |
| 137 | const detail = redDetail(undefined, ["test/a.test.ts"], lines)!; | |
| 138 | expect(detail.failures).toHaveLength(MAX_FAILURES); | |
| 139 | expect(detail.failures!.every((f) => f.file === "test/a.test.ts")).toBe(true); | |
| 140 | expect(detail.files).toEqual(["test/a.test.ts"]); | |
| 141 | expect(redDetail(undefined, [], lines)).toBeUndefined(); | |
| 142 | }); | |
| 143 | ||
| 144 | test("the issue says only what the run recorded", () => { | |
| 145 | reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 146 | expect(reports()[0]!.body).not.toContain("run alone"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the terminal's Flow dialog counts the open report issues", () => { | |
| 150 | process.env.FORGE_SENTINEL_REPO = "alice/app"; | |
| 151 | try { | |
| 152 | expect(flowLines(app.db).join("\n")).toContain("Full suite report: 0 open issues"); | |
| 153 | reportRed(app, repo, red(VERIFY_ENV, "a".repeat(40))); | |
| 154 | expect(flowLines(app.db).join("\n")).toContain("Full suite report: 2 open issues labelled full-suite"); | |
| 155 | } finally { | |
| 156 | delete process.env.FORGE_SENTINEL_REPO; | |
| 157 | } | |
| 158 | }); | |
| 159 | }); |
test/pipeline-path.test.ts +12
| @@ -206,3 +206,15 @@ test("a merged pull into an app is landed until production has served a deploy s | ||
| 206 | 206 | expect(stepOfPull(app.db, repo, pull, live)).toEqual({ step: "live", why: "Landed and live in production.", done: true }); |
| 207 | 207 | expect(await read(`/alice/shipped/pulls/${opened.value.number}`)).toContain('data-path="live"'); |
| 208 | 208 | }); |
| 209 | ||
| 210 | test("/pipeline links the full-suite report's issues, and that link answers", async () => { | |
| 211 | process.env["FORGE_SENTINEL_REPO"] = "alice/open"; | |
| 212 | try { | |
| 213 | const body = await (await fetch(`${servers.url}/pipeline`)).text(); | |
| 214 | expect(body).toContain('id="full-suite"'); | |
| 215 | expect(body).toContain('href="/alice/open/issues?label=full-suite"'); | |
| 216 | expect((await fetch(`${servers.url}/alice/open/issues?label=full-suite`)).status).toBe(200); | |
| 217 | } finally { | |
| 218 | delete process.env["FORGE_SENTINEL_REPO"]; | |
| 219 | } | |
| 220 | }); |
new above
offline
'and failed again when run alone' is not established by anything here. State only what the run recorded.