Public: anyone can read this; the crew can change it.
A green deploy says its tests passed: #962's testsClause lands, with its deploy tests in their own file #1162
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/987-a-green-deploy-says-its-tests-passed-962 → main 6 commits fork 4 threads · 4 unresolved
10 files
changed +329
−14
docs/STUDIO.md +9
| @@ -527,3 +527,12 @@ It never says _shipped_ before production redeployed, never hides that a | ||
| 527 | 527 | run cost money, never shows a preview that is not the pull's real head, and |
| 528 | 528 | never invents an answer for a card. The complexity is removed from the |
| 529 | 529 | surface, not from the system. |
| 530 | ||
| 531 | **What a green deploy says about its tests.** One clause, `testsClause` | |
| 532 | (`src/web/studio/words.ts`): "its tests passed" when the app declares a test | |
| 533 | and it ran, "it answered, no tests were run" when it declares none. It is read | |
| 534 | from the finished deploy's summary (see `finishRun`) and drawn on the pull's | |
| 535 | note, the look card in the home's needs-you (`decisionsFor`), and the previews | |
| 536 | panel (only when the check is of the commit being served). The terminal draws | |
| 537 | none of it: it shows the app's shell, not the studio's words, so nothing there | |
| 538 | changed. |
scripts/gate.ts +1
| @@ -155,6 +155,7 @@ export const GATE: readonly string[] = [ | ||
| 155 | 155 | "test/apps-identity.test.ts", |
| 156 | 156 | "test/apps-new.test.ts", |
| 157 | 157 | "test/apps-slow-test.test.ts", |
| 158 | "test/apps-tests-clause.test.ts", | |
| 158 | 159 | "test/apps-adversarial.test.ts", |
| 159 | 160 | "test/apps-busy.test.ts", |
| 160 | 161 | "test/apps-token.test.ts", |
src/apps/runtime.ts +6 −1
| @@ -156,6 +156,11 @@ const BUSY_GRACE_MS = 45 * 60_000; | ||
| 156 | 156 | * the studio reads the same constant to word its badge. */ |
| 157 | 157 | export const VERIFYING_NOTE = "serving — still verifying its tests"; |
| 158 | 158 | |
| 159 | /** What a finished deploy's summary ends with: the studio words its green | |
| 160 | * from these, so a pull says whether its tests ran (`words.ts`). */ | |
| 161 | export const TESTS_PASSED_TAIL = " · its tests passed"; | |
| 162 | export const NO_TESTS_TAIL = " · no tests declared, none run"; | |
| 163 | ||
| 159 | 164 | export interface EnvStatus { |
| 160 | 165 | readonly env: EnvName; |
| 161 | 166 | readonly state: EnvState; |
| @@ -1112,7 +1117,7 @@ export class AppsRuntime { | ||
| 1112 | 1117 | if (tested.ok) { |
| 1113 | 1118 | const done = finishRun(this.app.db, run.id, { |
| 1114 | 1119 | state: "ok", |
| 1115 | summary: `running at ${url}`, | |
| 1120 | summary: `running at ${url}${live.manifest.test === null ? NO_TESTS_TAIL : TESTS_PASSED_TAIL}`, | |
| 1116 | 1121 | }); |
| 1117 | 1122 | write(`done: ${url}`); |
| 1118 | 1123 | if (done && state.env === "production") await this.noteCarried(repo, done); |
src/core/runs.ts +6
| @@ -252,6 +252,12 @@ export function markServing(db: Database, runId: string): Run | undefined { | ||
| 252 | 252 | * Finish a run. Only a running run finishes; a cancelled one stays |
| 253 | 253 | * cancelled however late its worker reports in. Returns the final row, or |
| 254 | 254 | * undefined when nothing changed. |
| 255 | * | |
| 256 | * A deploy's `summary` ends with `TESTS_PASSED_TAIL` or `NO_TESTS_TAIL` | |
| 257 | * (`apps/runtime.ts`); `testsClause` (`web/studio/words.ts`) reads it back by | |
| 258 | * suffix. That is a contract kept in free text, chosen over a new column to | |
| 259 | * avoid a migration; both sides use the exported constants, never literals, | |
| 260 | * and a run without either tail (older runs) simply says nothing. | |
| 255 | 261 | */ |
| 256 | 262 | export function finishRun( |
| 257 | 263 | db: Database, |
src/web/studio/asks.ts +10 −4
| @@ -42,6 +42,7 @@ import { | ||
| 42 | 42 | activeRunFor, |
| 43 | 43 | automaticRunsSince, |
| 44 | 44 | latestRunsForIssues, |
| 45 | type Run, | |
| 45 | 46 | } from "../../core/runs.ts"; |
| 46 | 47 | import { getUserById, type User } from "../../core/users.ts"; |
| 47 | 48 | import { |
| @@ -62,6 +63,7 @@ import { btn, card, icon, row } from "../ui.ts"; | ||
| 62 | 63 | import { lookUrl } from "../../core/looks.ts"; |
| 63 | 64 | import { lookThumb, thumbLook } from "../pages/looks.ts"; |
| 64 | 65 | import { changeHref } from "./nouns.ts"; |
| 66 | import { testsClause } from "./words.ts"; | |
| 65 | 67 | |
| 66 | 68 | /** Repos narrowed to those with an app record, with the records — the one |
| 67 | 69 | * join `appsOf` and `writableAppsOf` share. */ |
| @@ -225,6 +227,8 @@ interface AskCandidate { | ||
| 225 | 227 | * differently for a person's own change than for the crew's (below). |
| 226 | 228 | * Absent for "paused", which names no author at all. */ |
| 227 | 229 | readonly authorIsAgent?: boolean; |
| 230 | /** For "look": the green check, whose summary says what its tests did. */ | |
| 231 | readonly check?: Run; | |
| 228 | 232 | /** For "review": why the agent's change waits for a person, and since when. */ |
| 229 | 233 | readonly reviewWait?: ReviewWait; |
| 230 | 234 | /** For "stopped": how many automatic tries, and whether the decider itself stopped it. */ |
| @@ -348,6 +352,7 @@ function askCandidatesFor( | ||
| 348 | 352 | pull, |
| 349 | 353 | who, |
| 350 | 354 | authorIsAgent: issue.authorIsAgent, |
| 355 | check, | |
| 351 | 356 | }); |
| 352 | 357 | } else if ( |
| 353 | 358 | check?.state === "ok" && |
| @@ -910,6 +915,7 @@ export function decisionsFor( | ||
| 910 | 915 | }; |
| 911 | 916 | if (c.kind === "look" && c.pull) { |
| 912 | 917 | const picture = pictureOf(c.repo, c.pull); |
| 918 | const clause = testsClause(c.check ?? null); | |
| 913 | 919 | // The crew's own words say who finished it; a person's own change |
| 914 | 920 | // gets the wording that is true of it — nobody "finished" it for |
| 915 | 921 | // them, it is simply green and waiting. |
| @@ -920,11 +926,11 @@ export function decisionsFor( | ||
| 920 | 926 | pull: c.pull, |
| 921 | 927 | who: c.who, |
| 922 | 928 | says: c.authorIsAgent |
| 923 | ? `${c.who} finished "${c.pull.title}" and the check is green. Look at the preview, then ship it or say what is off.` | |
| 924 | : `"${c.pull.title}" is green — ready for a look. Ship it, or say what is off.`, | |
| 929 | ? `${c.who} finished "${c.pull.title}" and the check is green${clause === "" ? "" : ` (${clause})`}. Look at the preview, then ship it or say what is off.` | |
| 930 | : `"${c.pull.title}" is green — ${clause === "" ? "" : `${clause}, `}ready for a look. Ship it, or say what is off.`, | |
| 925 | 931 | short: c.authorIsAgent |
| 926 | ? `${c.who} finished it; the check is green` | |
| 927 | : `green — ready for a look`, | |
| 932 | ? `${c.who} finished it; the check is green${clause === "" ? "" : ` (${clause})`}` | |
| 933 | : `green — ${clause === "" ? "" : `${clause}, `}ready for a look`, | |
| 928 | 934 | ...(picture === undefined ? {} : { picture }), |
| 929 | 935 | }; |
| 930 | 936 | } |
src/web/studio/previews.ts +21 −4
| @@ -17,10 +17,11 @@ import { | ||
| 17 | 17 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 18 | 18 | import { listPulls, type Pull } from "../../core/pulls.ts"; |
| 19 | 19 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 20 | import { listRuns } from "../../core/runs.ts"; | |
| 20 | import { latestRunsForIssues, listRuns, type Run } from "../../core/runs.ts"; | |
| 21 | 21 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 22 | import { readRunLogTail } from "../../runs/log.ts"; |
| 23 | 23 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 24 | import { testsClause } from "./words.ts"; | |
| 24 | 25 | import { html, when, type Html } from "../html.ts"; |
| 25 | 26 | import { fragment } from "../layout.ts"; |
| 26 | 27 | import { |
| @@ -81,6 +82,17 @@ function previewBadge(env: EnvStatus | undefined): Html { | ||
| 81 | 82 | return badge(word, { tone, icon: ic }); |
| 82 | 83 | } |
| 83 | 84 | |
| 85 | /** The tests clause for a serving preview, only when the green check is of | |
| 86 | * the very commit being served — a stale check says nothing. */ | |
| 87 | export function previewVerdict( | |
| 88 | env: EnvStatus | undefined, | |
| 89 | check: Run | undefined, | |
| 90 | ): string | undefined { | |
| 91 | if (env?.state !== "running" || check?.state !== "ok") return undefined; | |
| 92 | if (check.commitOid !== env.commitOid) return undefined; | |
| 93 | return testsClause(check) || undefined; | |
| 94 | } | |
| 95 | ||
| 84 | 96 | /** |
| 85 | 97 | * One open pull's preview, as one row: its host is the click, its state is |
| 86 | 98 | * the badge, and its meta line says what the change page's Preview card |
| @@ -102,6 +114,7 @@ function previewRow( | ||
| 102 | 114 | pull: Pull, |
| 103 | 115 | env: EnvStatus | undefined, |
| 104 | 116 | writer: boolean, |
| 117 | check: Run | undefined, | |
| 105 | 118 | ): Html { |
| 106 | 119 | const href = |
| 107 | 120 | runtime === undefined |
outdated
The commitOid match and the fallback order (error, failedDeploy, note, verdict) are untested. Cover a stale-commit check showing no clause. |
||
| @@ -113,7 +126,9 @@ function previewRow( | ||
| 113 | 126 | : runtime.urlFor(record, previewEnv(pull.number)).replace(/^https?:\/\//, ""); |
| 114 | 127 | const bits: Html[] = [html`#${pull.number} ${pull.title}`]; |
| 115 | 128 | if (env !== undefined) bits.push(...envLine(base, env)); |
| 116 | const say = env?.error ?? env?.failedDeploy?.error ?? env?.note; | |
| 129 | // A serving preview whose deploy finished green says what its tests did. | |
| 130 | const say = | |
| 131 | env?.error ?? env?.failedDeploy?.error ?? env?.note ?? previewVerdict(env, check); | |
| 117 | 132 | if (say) bits.push(html`${say}`); |
| 118 | 133 | const asleep = env === undefined ? null : dormantNote(env); |
| 119 | 134 | if (asleep !== null) bits.push(asleep); |
| @@ -160,6 +175,7 @@ function previewsPanel( | ||
| 160 | 175 | openPulls: readonly Pull[], |
| 161 | 176 | envs: readonly EnvStatus[], |
| 162 | 177 | writer: boolean, |
| 178 | checks: ReadonlyMap<string, Run>, | |
| 163 | 179 | ): Html | null { |
| 164 | 180 | if (openPulls.length === 0) return null; |
| 165 | 181 | const byPull = new Map( |
| @@ -169,7 +185,7 @@ function previewsPanel( | ||
| 169 | 185 | ); |
| 170 | 186 | return listCard( |
| 171 | 187 | openPulls.map((p) => |
| 172 | previewRow(base, runtime, record, p, byPull.get(p.number), writer), | |
| 188 | previewRow(base, runtime, record, p, byPull.get(p.number), writer, checks.get(p.id)), | |
| 173 | 189 | ), |
| 174 | 190 | { |
| 175 | 191 | head: html`${icon("rocket")} |
| @@ -274,7 +290,8 @@ export function appPanels( | ||
| 274 | 290 | const runtime = appsRuntime(ctx.app); |
| 275 | 291 | const envs: EnvStatus[] = envsFor(ctx.app, ctx.auth.user, record); |
| 276 | 292 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); |
| 277 | const previews = previewsPanel(base, runtime, record, openPulls, envs, writer); | |
| 293 | const checks = latestRunsForIssues(db, openPulls.map((p) => p.id)); | |
| 294 | const previews = previewsPanel(base, runtime, record, openPulls, envs, writer, checks); | |
| 278 | 295 | const production = envs.find((e) => e.env === "production"); |
| 279 | 296 | const url = runtime?.urlFor(record, "production"); |
| 280 | 297 | const tail = productionTail(ctx, repo, production, writer); |
src/web/studio/words.ts +25 −3
| @@ -10,7 +10,12 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import { ancestorsOf } from "../../git/merge.ts"; |
| 13 | import type { AppsRuntime, EnvStatus } from "../../apps/runtime.ts"; | |
| 13 | import { | |
| 14 | NO_TESTS_TAIL, | |
| 15 | TESTS_PASSED_TAIL, | |
| 16 | type AppsRuntime, | |
| 17 | type EnvStatus, | |
| 18 | } from "../../apps/runtime.ts"; | |
| 14 | 19 | import type { AppRecord } from "../../core/apps.ts"; |
| 15 | 20 | import { canEnterApp, getRepoById } from "../../core/repos.ts"; |
| 16 | 21 | import type { User } from "../../core/users.ts"; |
| @@ -151,11 +156,28 @@ function detailNote(detail: CheckDetail): string { | ||
| 151 | 156 | return `${detail.passed} passed, ${detail.failed} failed: ${names}`; |
| 152 | 157 | } |
| 153 | 158 | |
| 159 | /** | |
| 160 | * What a green deploy says about its tests, from the summary the runtime | |
| 161 | * wrote when it finished: the one clause every surface that draws a green | |
| 162 | * change uses. Empty for a run that predates the clause. | |
| 163 | */ | |
| 164 | export function testsClause(run: Run | null): string { | |
| 165 | const summary = run?.summary ?? ""; | |
| 166 | if (summary.endsWith(TESTS_PASSED_TAIL)) return "its tests passed"; | |
| 167 | if (summary.endsWith(NO_TESTS_TAIL)) return "it answered, no tests were run"; | |
| 168 | return ""; | |
| 169 | } | |
| 170 | ||
| 154 | 171 | /** A single open pull's check, in the studio's own words — never dev's |
| 155 | 172 | * "Checks passed/failed": the same five words as the rest of the page. */ |
| 156 | 173 | function checkRunWord(check: CheckSummary): { word: Word; note: string } { |
| 157 | if (check.state === "ok") | |
| 158 | return { word: "look", note: "green — ready for a look" }; | |
| 174 | if (check.state === "ok") { | |
| 175 | const clause = testsClause(check.run); | |
| 176 | return { | |
| 177 | word: "look", | |
| 178 | note: `green — ${clause === "" ? "" : `${clause}, `}ready for a look`, | |
| 179 | }; | |
| 180 | } | |
| 159 | 181 | if (check.state === "failed") { |
| 160 | 182 | const base = |
| 161 | 183 | check.run?.servingAt != null |
test/apps-tests-clause.test.ts added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | /** | |
| 2 | * A green deploy says what its tests did (words.ts `testsClause`): the | |
| 3 | * pull's note reads "its tests passed" when the app's suite ran, and says | |
| 4 | * nothing was run when the app declares no test. Real deploys, so this file | |
| 5 | * owns its forge and data directory: `test/apps.test.ts` shares one forge | |
| 6 | * across its tests and counts previews, which these would leak into. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 15 | import { newApp } from "../src/actions/apps.ts"; | |
| 16 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 17 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 18 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 19 | import { getPull } from "../src/core/pulls.ts"; | |
| 20 | import type { Repo } from "../src/core/repos.ts"; | |
| 21 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { repoDir } from "../src/git/repos.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { pullWord, servingHistory } from "../src/web/studio/words.ts"; | |
| 26 | ||
| 27 | describe("a green deploy says what its tests did", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let runtime: AppsRuntime; | |
| 32 | let alice: User; | |
| 33 | let repo: Repo; | |
| 34 | let record: AppRecord; | |
| 35 | ||
| 36 | async function settled(where: (run: Run) => boolean): Promise<Run> { | |
| 37 | const deadline = Date.now() + 60_000; | |
| 38 | while (Date.now() < deadline) { | |
| 39 | const run = listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 40 | where, | |
| 41 | ); | |
| 42 | if (run && ["ok", "failed", "cancelled"].includes(run.state)) return run; | |
| 43 | await Bun.sleep(100); | |
| 44 | } | |
| 45 | throw new Error("run did not settle in time"); | |
| 46 | } | |
| 47 | ||
| 48 | const word = (number: number) => | |
| 49 | pullWord( | |
| 50 | { | |
| 51 | db: app.db, | |
| 52 | dir: repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 53 | runtime, | |
| 54 | record, | |
| 55 | history: servingHistory(), | |
| 56 | viewer: alice, | |
| 57 | }, | |
| 58 | getPull(app.db, repo.id, number)!, | |
| 59 | ); | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-clause-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | runtime = new AppsRuntime(app, { | |
| 66 | domain: "apps.test", | |
| 67 | scheme: "http", | |
| 68 | publicPort: servers.port, | |
| 69 | forgeUrl: servers.url, | |
| 70 | concurrency: 2, | |
| 71 | }); | |
| 72 | await runtime.start(); | |
| 73 | const created = await createUser(app.db, { | |
| 74 | trusted: true, | |
| 75 | username: "alice", | |
| 76 | email: "[email protected]", | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst: true, | |
| 79 | }); | |
| 80 | if (!created.ok) throw new Error(created.error); | |
| 81 | alice = created.value; | |
| 82 | const made = await newApp(app, alice, { | |
| 83 | name: "clause", | |
| 84 | description: "Tests clause", | |
| 85 | visibility: "public", | |
| 86 | }); | |
| 87 | if (!made.ok) throw new Error(made.error); | |
| 88 | repo = made.value.repo; | |
| 89 | record = made.value.app; | |
| 90 | await settled((r) => r.env === "production"); | |
| 91 | }, 90_000); | |
| 92 | ||
| 93 | afterAll(async () => { | |
| 94 | await runtime.stop(); | |
| 95 | servers.stop(); | |
| 96 | app.close(); | |
| 97 | await rm(dataDir, { recursive: true, force: true }); | |
| 98 | }); | |
| 99 | ||
| 100 | test("the suite passed: the pull says its tests passed", async () => { | |
| 101 | const committed = await commitToBranch(app, alice, repo, { | |
| 102 | branch: "passing", | |
| 103 | base: repo.defaultBranch, | |
| 104 | message: "A passing test", | |
| 105 | files: [ | |
| 106 | { | |
| 107 | path: "src/server.test.ts", | |
| 108 | content: | |
| 109 | 'import { expect, test } from "bun:test";\ntest("passes", () => { expect(1).toBe(1); });\n', | |
| 110 | }, | |
| 111 | ], | |
| 112 | }); | |
| 113 | expect(committed.ok).toBe(true); | |
| 114 | if (!committed.ok) return; | |
| 115 | const opened = await openPull(app, alice, repo, { | |
| 116 | title: "Passing", | |
| 117 | baseBranch: repo.defaultBranch, | |
| 118 | headBranch: "passing", | |
| 119 | }); | |
| 120 | expect(opened.ok).toBe(true); | |
| 121 | if (!opened.ok) return; | |
| 122 | const green = await settled( | |
| 123 | (r) => | |
| 124 | r.env === `pull-${opened.value.number}` && | |
| 125 | r.commitOid === committed.value.commitOid, | |
| 126 | ); | |
| 127 | expect(green.state).toBe("ok"); | |
| 128 | expect((await word(opened.value.number)).note).toBe( | |
| 129 | "green — its tests passed, ready for a look", | |
| 130 | ); | |
| 131 | const merged = await mergePull(app, alice, repo, opened.value, "merge"); | |
| 132 | expect(merged.ok).toBe(true); | |
| 133 | }, 120_000); | |
| 134 | ||
| 135 | test("an app that declares no test: the pull says nothing was run", async () => { | |
| 136 | const committed = await commitToBranch(app, alice, repo, { | |
| 137 | branch: "untested", | |
| 138 | base: repo.defaultBranch, | |
| 139 | message: "No tests declared", | |
| 140 | files: [ | |
| 141 | { | |
| 142 | path: "forge.json", | |
| 143 | content: `${JSON.stringify( | |
| 144 | { app: { run: "bun run src/server.ts", tty: "bun run src/tty.ts" } }, | |
| 145 | null, | |
| 146 | 2, | |
| 147 | )}\n`, | |
| 148 | }, | |
| 149 | ], | |
| 150 | }); | |
| 151 | expect(committed.ok).toBe(true); | |
| 152 | if (!committed.ok) return; | |
| 153 | const opened = await openPull(app, alice, repo, { | |
| 154 | title: "Untested", | |
| 155 | baseBranch: repo.defaultBranch, | |
| 156 | headBranch: "untested", | |
| 157 | }); | |
| 158 | expect(opened.ok).toBe(true); | |
| 159 | if (!opened.ok) return; | |
| 160 | const green = await settled( | |
| 161 | (r) => | |
| 162 | r.env === `pull-${opened.value.number}` && | |
| 163 | r.commitOid === committed.value.commitOid, | |
| 164 | ); | |
| 165 | expect(green.state).toBe("ok"); | |
| 166 | expect(await word(opened.value.number)).toEqual({ | |
| 167 | word: "look", | |
| 168 | note: "green — it answered, no tests were run, ready for a look", | |
| 169 | }); | |
| 170 | }, 120_000); | |
| 171 | }); |
test/asks.test.ts +37
| @@ -12,11 +12,14 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 12 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | import { TESTS_PASSED_TAIL } from "../src/apps/runtime.ts"; | |
| 15 | 16 | import { recordDecision, resolveOpen } from "../src/core/decisions.ts"; |
| 16 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 18 | 19 | import { getUserByName } from "../src/core/users.ts"; |
| 20 | import type { Ctx } from "../src/http/context.ts"; | |
| 19 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { decisionsFor } from "../src/web/studio/asks.ts"; | |
| 20 | 23 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | 24 | |
| 22 | 25 | quietLogs(); |
| @@ -225,4 +228,38 @@ describe("the asks page", () => { | ||
| 225 | 228 | check("failed"); |
| 226 | 229 | expect(await get(alice, "/")).not.toContain(card); |
| 227 | 230 | }); |
| 231 | ||
| 232 | test("a green change's card says what its tests did", async () => { | |
| 233 | const bot = getUserByName(app.db, "bot")!; | |
| 234 | const branch = await commitToBranch(app, getUserByName(app.db, "alice")!, repo, { | |
| 235 | branch: "bot/3-clause", | |
| 236 | base: "main", | |
| 237 | message: "Bot work", | |
| 238 | files: [{ path: "CLAUSE.md", content: "x\n" }], | |
| 239 | }); | |
| 240 | expect(branch.ok).toBe(true); | |
| 241 | const opened = await openPull(app, bot, repo, { | |
| 242 | title: "Bot clause", | |
| 243 | body: "", | |
| 244 | baseBranch: "main", | |
| 245 | headBranch: "bot/3-clause", | |
| 246 | }); | |
| 247 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 248 | const pull = opened.value; | |
| 249 | const run = createRun(app.db, { | |
| 250 | repoId: repo.id, | |
| 251 | kind: "check", | |
| 252 | issueId: pull.id, | |
| 253 | env: `pull-${pull.number}`, | |
| 254 | commitOid: pull.headOid, | |
| 255 | }); | |
| 256 | claimRun(app.db, run.id); | |
| 257 | finishRun(app.db, run.id, { state: "ok", summary: `running at x${TESTS_PASSED_TAIL}` }); | |
| 258 | const card = decisionsFor( | |
| 259 | { app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, | |
| 260 | [repo], | |
| 261 | ).find((d) => d.id === `look:${pull.id}`); | |
| 262 | expect(card?.says).toContain("its tests passed"); | |
| 263 | expect(card?.short).toContain("its tests passed"); | |
| 264 | }); | |
| 228 | 265 | }); |
test/studio-words.test.ts +43 −2
| @@ -30,15 +30,22 @@ import { join } from "node:path"; | ||
| 30 | 30 | import { createApp, type App } from "../src/app.ts"; |
| 31 | 31 | import { enableApp } from "../src/actions/apps.ts"; |
| 32 | 32 | import { previewEnv, type EnvName } from "../src/apps/data.ts"; |
| 33 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 33 | import { | |
| 34 | AppsRuntime, | |
| 35 | NO_TESTS_TAIL, | |
| 36 | TESTS_PASSED_TAIL, | |
| 37 | type EnvStatus, | |
| 38 | } from "../src/apps/runtime.ts"; | |
| 34 | 39 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 35 | 40 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 36 | 41 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 37 | 42 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 38 | 43 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 39 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 44 | import { claimRun, createRun, finishRun, type Run } from "../src/core/runs.ts"; | |
| 40 | 45 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 41 | 46 | import { serve, type Servers } from "../src/http/server.ts"; |
| 47 | import { previewVerdict } from "../src/web/studio/previews.ts"; | |
| 48 | import { testsClause } from "../src/web/studio/words.ts"; | |
| 42 | 49 | |
| 43 | 50 | /** |
| 44 | 51 | * Dev's own words, each with the reason it must never reach a studio page. |
| @@ -883,3 +890,37 @@ describe("dev's vocabulary never reaches a studio page", () => { | ||
| 883 | 890 | ); |
| 884 | 891 | }); |
| 885 | 892 | }); |
| 893 | ||
| 894 | describe("testsClause", () => { | |
| 895 | const run = (summary: string) => ({ summary }) as unknown as Run; | |
| 896 | ||
| 897 | test("a deploy from before the clause existed says nothing about tests; the two tails say their own", () => { | |
| 898 | expect(testsClause(run("running at http://x"))).toBe(""); | |
| 899 | expect(testsClause(null)).toBe(""); | |
| 900 | expect(testsClause(run(`running at http://x${TESTS_PASSED_TAIL}`))).toBe( | |
| 901 | "its tests passed", | |
| 902 | ); | |
| 903 | expect(testsClause(run(`running at http://x${NO_TESTS_TAIL}`))).toBe( | |
| 904 | "it answered, no tests were run", | |
| 905 | ); | |
| 906 | }); | |
| 907 | }); | |
| 908 | ||
| 909 | describe("a preview row's tests verdict", () => { | |
| 910 | const run = (summary: string, commitOid: string, state = "ok") => | |
| 911 | ({ state, summary, commitOid }) as unknown as Run; | |
| 912 | const env = (state: string, commitOid: string) => | |
| 913 | ({ state, commitOid }) as unknown as EnvStatus; | |
| 914 | ||
| 915 | test("speaks only when the green check is of the commit being served", () => { | |
| 916 | const green = run(`running at x${TESTS_PASSED_TAIL}`, "abc"); | |
| 917 | expect(previewVerdict(env("running", "abc"), green)).toBe("its tests passed"); | |
| 918 | expect(previewVerdict(env("running", "def"), green)).toBeUndefined(); | |
| 919 | expect(previewVerdict(env("asleep", "abc"), green)).toBeUndefined(); | |
| 920 | expect(previewVerdict(undefined, green)).toBeUndefined(); | |
| 921 | expect( | |
| 922 | previewVerdict(env("running", "abc"), run(`x${TESTS_PASSED_TAIL}`, "abc", "failed")), | |
| 923 | ).toBeUndefined(); | |
| 924 | expect(previewVerdict(env("running", "abc"), undefined)).toBeUndefined(); | |
| 925 | }); | |
| 926 | }); |
new above
offline
New wording for
saysandshortwith no test. Add one in the asks suite that fails without the clause.