diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 7d4bfbdf..fb383123 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -22,7 +22,7 @@ import { } from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); -const COVERAGE_PATH = "COVERAGE.md"; +export const COVERAGE_PATH = "COVERAGE.md"; /** The repository these pages document; the token form's name and description derive from it. */ const REPO_SLUG = "Vivswan/github-settings-as-code"; diff --git a/.github/scripts/gen-gaps-index.ts b/.github/scripts/gen-gaps-index.ts index b0797330..5c87da70 100644 --- a/.github/scripts/gen-gaps-index.ts +++ b/.github/scripts/gen-gaps-index.ts @@ -11,7 +11,7 @@ import { join } from "node:path"; const ROOT = join(import.meta.dir, "..", ".."); const GAPS_DIR = "src/upstream-gaps"; -const INDEX_PATH = `${GAPS_DIR}/index.ts`; +export const INDEX_PATH = `${GAPS_DIR}/index.ts`; export const NON_GAP_FILE_NAMES: ReadonlySet = new Set(["index.ts", "gap.ts"]); diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts new file mode 100644 index 00000000..cfb6715e --- /dev/null +++ b/.github/scripts/generated.ts @@ -0,0 +1,75 @@ +/** + * The one table of committed generated output, derived from the generators' own registries, and the drift check + * behind `bun run build:check`: every generator runs, then every registered path must be tracked and unchanged. + */ + +import { join } from "node:path"; +import { GENERATED_REGIONS } from "./gen-action-docs.js"; +import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js"; +import { INDEX_PATH } from "./gen-gaps-index.js"; + +const ROOT = join(import.meta.dir, "..", ".."); + +export interface GeneratedOutput { + /** The committed output, repo-relative. */ + readonly path: string; + /** The script that writes it, repo-relative; `bun ` regenerates it in place. */ + readonly generator: string; + /** Marker-delimited regions inside an authored file (lib/generated-regions.ts), or the whole file. */ + readonly kind: "regions" | "file"; +} + +function regions(generator: string, paths: readonly string[]): GeneratedOutput[] { + return paths.map((path) => ({ path, generator, kind: "regions" })); +} + +/** A page two generators write into (docs/reference/inputs.md) has one row per generator. */ +export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ + { + path: "lib/settings.schema.json", + generator: ".github/scripts/gen-settings-schema.ts", + kind: "file", + }, + ...regions(".github/scripts/gen-docs.ts", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), + ...regions(".github/scripts/gen-action-docs.ts", Object.keys(GENERATED_REGIONS)), + { path: INDEX_PATH, generator: ".github/scripts/gen-gaps-index.ts", kind: "file" }, +]; + +/** The distinct paths, in table order. */ +export function generatedPaths(): string[] { + return [...new Set(GENERATED_OUTPUTS.map((output) => output.path))]; +} + +/** Runs `argv` at the repository root on the terminal's stdio; the exit code is the verdict. */ +function run(argv: string[]): number { + return Bun.spawnSync(argv, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }).exitCode; +} + +if (import.meta.main) { + for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { + if (run([process.execPath, generator]) !== 0) { + console.error(`build:check: ${generator} failed`); + process.exit(1); + } + } + const paths = generatedPaths(); + // Working tree against the index: a regenerated file already staged passes, a stale one fails. git lists the + // drifted paths itself; an untracked registered path is listed the same way. + const untracked = Bun.spawnSync( + ["git", "ls-files", "--others", "--exclude-standard", "--", ...paths], + { cwd: ROOT, stderr: "inherit" }, + ); + if (untracked.exitCode !== 0) { + console.error("build:check: git ls-files failed"); + process.exit(1); + } + const drifted = run(["git", "diff", "--exit-code", "--stat", "--", ...paths]) !== 0; + if (drifted || untracked.stdout.length > 0) { + process.stdout.write(untracked.stdout); + console.error( + "build:check: generated output drifted from the committed tree; commit the files listed above", + ); + process.exit(1); + } + console.log(`build:check: ${paths.length} generated files match their generators`); +} diff --git a/.github/scripts/lib/generated-regions.ts b/.github/scripts/lib/generated-regions.ts index dee1c4e6..b1138b18 100644 --- a/.github/scripts/lib/generated-regions.ts +++ b/.github/scripts/lib/generated-regions.ts @@ -32,7 +32,8 @@ export function markerSyntaxFor(path: string): MarkerSyntax { } // The name is spliced into a regex unescaped, so the grammar admits only regex-literal characters. -const REGION_NAME = /^[a-z0-9-]+$/; +const REGION_NAME_CLASS = "[a-z0-9-]+"; +const REGION_NAME = new RegExp(`^${REGION_NAME_CLASS}$`); function markerText(kind: "BEGIN" | "END", name: string): string { const hint = kind === "BEGIN" ? String.raw`(?: \([^)\n]*\))?` : ""; @@ -93,6 +94,11 @@ function markerSpans( }); } +/** True when `text` carries a BEGIN marker of any region in `syntax`: the scan the generated-output table is pinned against. */ +export function hasGeneratedRegion(text: string, syntax: MarkerSyntax): boolean { + return markerSpans(text, "BEGIN", REGION_NAME_CLASS, syntax).length > 0; +} + /** Region `name`'s marker spans: exactly one BEGIN and one END, in that order, else a throw. */ export function regionBounds( text: string, diff --git a/AGENTS.md b/AGENTS.md index 68703d70..37dd02dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Code is the source of truth: this section holds only the rules and the decisions ### Hard rules -- Generated artifacts (`lib/settings.schema.json`, the generated docs, the generated `action.yml` regions) are regenerated, never hand-edited; `bun run build:check` fails on drift. +- Generated artifacts (`lib/settings.schema.json`, `src/upstream-gaps/index.ts`, the generated docs and `action.yml` regions) are regenerated, never hand-edited; `.github/scripts/generated.ts` lists them and `bun run build:check` fails on drift. - `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built, never committed on main; the packaged `build` branch carries them. - Every GitHub list call goes through `listAll()` or `listAllEnveloped()`, and every API error through `call()`/`throwFor()`, so the permission policy holds (`src/sections/contract/requests.ts`). - The import layering of `src/` is declared in `architecture.yml`; a new cross-layer import is a deliberate edit to that file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ada1f29..b53b0699 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all ## Toolchain - `src/` is TypeScript built with [bun](https://bun.com). The scripts in `package.json` are the commands; `bun run check` is the whole local gate. -- `lib/settings.schema.json` is the one committed generated artifact; `bun run build:check` fails when it or the generated docs drift. +- Committed generated output is the table in `.github/scripts/generated.ts`: `lib/settings.schema.json`, `src/upstream-gaps/index.ts`, and the generated regions of `action.yml`, `COVERAGE.md`, and the docs pages. +- `bun run build:check` regenerates every table entry and fails on drift. - `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built where they are needed and never committed on `main`. Every runtime dependency is compiled into them. - [COVERAGE.md](COVERAGE.md) is the inventory of the supported API surface. A change that adds or extends a section keeps it in step. @@ -15,6 +16,19 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all - `bun run check:compat` (in `bun run check` and in CI) rejects a malformed marker and any marker whose major is at or below `package.json`'s, and prints the remaining markers grouped by major. - A release PR's tree already carries the version it cuts, so the same check makes a major release PR unmergeable until every marker for that major is deleted on `main` first: with 3.0.0 in preparation, every v3-marked path goes before v3 cuts. +## Code conventions + +- Line caps: code wraps at biome's `lineWidth` of 100. The fleet's check-file-size caps source, test, workflow, and shell lines at 256 characters; markdown prose has no width cap. A comment block is at most 10 lines. +- Markdown keeps one source line per paragraph or list item, so a long item is split into items, never wrapped. +- A source file under `src/` or `.github/scripts/` opens with a one-paragraph header comment saying what the file owns; test files need none. +- Tests live in two places: a section's unit tests sit beside it in `src/sections//`; everything else is under `test/`, mirroring `src/`. + +## Tests + +- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` from `test/temp-dir.ts`, or a try/finally of its own. A fixture that outlives one test removes its dir when it ends (the release-pipeline fixture in afterAll, the e2e bundle on process exit). +- An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log. +- A repository-relative path in a test resolves from `ROOT` in `test/root.ts`; a file beside the test resolves from `import.meta.dir`. + ## End-to-end tests The end-to-end tests build the bundle to a temp path and run it as a subprocess against a mock GitHub API, so they exercise the same single-file bundle a release ships. diff --git a/package.json b/package.json index 0365ed8a..cc53409e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "build:schema": "bun .github/scripts/gen-settings-schema.ts", "build:docs": "bun .github/scripts/gen-docs.ts", "build:action-docs": "bun .github/scripts/gen-action-docs.ts", - "build:check": "bun run build:schema && git diff --exit-code lib/settings.schema.json && ! git ls-files --others --exclude-standard lib/settings.schema.json | grep . && bun run build:docs && bun run build:action-docs && git diff --exit-code README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md && ! git ls-files --others --exclude-standard README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md | grep .", + "build:check": "bun .github/scripts/generated.ts", "prepare": "lefthook install || true" }, "dependencies": { diff --git a/src/sections/actions_secrets/actions_secrets.test.ts b/src/sections/actions_secrets/actions_secrets.test.ts index c5e8f161..93aa8d92 100644 --- a/src/sections/actions_secrets/actions_secrets.test.ts +++ b/src/sections/actions_secrets/actions_secrets.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test"; import { executePlan } from "../../../src/engine/execute.js"; import { runForRepo, validateSettingsDoc } from "../../../src/engine/orchestrate.js"; import { type GithubClient, SECRET_RESPONSE_WITHHELD } from "../../../src/github/api.js"; -import { type Io, maskRegistry } from "../../../src/io.js"; import { MOCK_SECRETS_PUBLIC_KEY, mockSodiumReady, unsealSecretValue, } from "../../../test/e2e/mock/secrets.js"; +import { captureIo } from "../../../test/io/capture.js"; import { MockApi } from "../../../test/mock-api.js"; import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js"; import { REPO } from "../../../test/sections/section-run.js"; @@ -376,20 +376,10 @@ describe("actions_secrets execution", () => { const api = new MockApi({ [LIST]: listOf(), [PUBLIC_KEY]: KEY_ROUTE }).allowMutations( "PUT /repos/o/r/actions/secrets/DEPLOY_TOKEN", ); - const masked: string[] = []; const mutationsAtMaskTime: number[] = []; - const logs: string[] = []; - const io: Io = { - annotate: (level, message) => logs.push(`${level}: ${message}`), - log: (line) => logs.push(line), - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry((value) => { - mutationsAtMaskTime.push(api.mutations().length); - masked.push(value); - }), - }; + const { io, masks, logs, annotations } = captureIo(() => + mutationsAtMaskTime.push(api.mutations().length), + ); const validated = validateSettingsDoc( { actions_secrets: [{ name: "DEPLOY_TOKEN", value: "$DEPLOY_TOKEN" }] }, "settings.yml", @@ -412,12 +402,12 @@ describe("actions_secrets execution", () => { io, ); expect(result.result).toBe("applied"); - expect(masked).toEqual(["s3cret-plaintext"]); + expect(masks).toEqual(["s3cret-plaintext"]); expect(mutationsAtMaskTime).toEqual([0]); expect(unsealSecretValue(sealedPayload(api.mutations()[0]).encrypted_value)).toBe( "s3cret-plaintext", ); - expect(logs.join("\n")).not.toContain("s3cret-plaintext"); + expect([...annotations, ...logs].join("\n")).not.toContain("s3cret-plaintext"); expect(logs).toContain('actions_secrets: created secret "DEPLOY_TOKEN"'); }); }); diff --git a/src/sections/branches/branches.test.ts b/src/sections/branches/branches.test.ts index 4d21b17e..b8d38b17 100644 --- a/src/sections/branches/branches.test.ts +++ b/src/sections/branches/branches.test.ts @@ -3,7 +3,6 @@ import { executePlan } from "../../../src/engine/execute.js"; import { SectionSelection } from "../../../src/engine/section-selection.js"; import { snapshotRepository } from "../../../src/engine/snapshot.js"; import type { GithubClient } from "../../../src/github/api.js"; -import { type Io, maskRegistry } from "../../../src/io.js"; import { type PlannedOp, planContext, @@ -13,6 +12,7 @@ import { allEndpoints, allGraphqlOps } from "../../../src/sections/registry.js"; import type { MustBeNever } from "../../../src/types.js"; import { buildState, type LiveState } from "../../../test/e2e/mock/state.js"; import type { Json } from "../../../test/e2e/mock/support.js"; +import { captureIo } from "../../../test/io/capture.js"; import { MockApi } from "../../../test/mock-api.js"; import { registryFake } from "../../../test/sections/fragment-fake.js"; import { provePlanIdempotent } from "../../../test/sections/plan-idempotence.js"; @@ -1753,15 +1753,7 @@ describe("branches snapshot", () => { 'Repository with the given name (a 404 here can also mean the resource does not exist). To fix, grant "Administration" ' + "(read and write) under the PAT's Repository permissions", ); - const annotations: string[] = []; - const io: Io = { - annotate: (level, message) => annotations.push(`${level}: ${message}`), - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }; + const { io, annotations } = captureIo(); const result = await snapshotRepository( api, { diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index 8e7abcae..399b0d53 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -8,8 +8,7 @@ import { OUTPUT_DECLS } from "../../src/action/io.js"; import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; import { FILTER_INPUTS, INPUT_DECLS, type InputDecl } from "../../src/flows/inputs.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; interface ActionYml { name: string; diff --git a/test/action/io.test.ts b/test/action/io.test.ts index d71bdba4..82b9c706 100644 --- a/test/action/io.test.ts +++ b/test/action/io.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { actionsIo } from "../../src/action/io.js"; import { writeSummary } from "../../src/flows/summary.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; /** A static import, a re-export, a dynamic import(), or a require() all quote the specifier; a comment mentioning it bare does not. */ function namesActionsCore(source: string): boolean { @@ -25,7 +26,7 @@ describe("the Io port boundary", () => { test("only src/action/ names @actions/core", () => { // Every other layer reaches the runner through the Io port, so redaction and capture have one place to stand. - const srcDir = join(import.meta.dir, "..", "..", "src"); + const srcDir = join(ROOT, "src"); const files = readdirSync(srcDir, { recursive: true }) as string[]; const offenders: string[] = []; let scanned = 0; @@ -50,7 +51,6 @@ describe("actionsIo", () => { summary: process.env.GITHUB_STEP_SUMMARY, output: process.env.GITHUB_OUTPUT, }; - const dirs: string[] = []; afterEach(() => { for (const [key, value] of [ ["GITHUB_STEP_SUMMARY", saved.summary], @@ -62,56 +62,49 @@ describe("actionsIo", () => { process.env[key] = value; } } - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } }); - const scratch = (): string => { - const dir = mkdtempSync(join(tmpdir(), "sac-io-")); - dirs.push(dir); - return dir; - }; + test("summary appends each block with one trailing newline, and skips when the runner file is unset", () => + withTempDir("sac-io-", (dir) => { + const file = join(dir, "summary.md"); + delete process.env.GITHUB_STEP_SUMMARY; + actionsIo.summary("dropped"); + process.env.GITHUB_STEP_SUMMARY = file; + writeSummary( + actionsIo, + { outcomes: [{ key: "repository", status: "drift", detail: ["has_wiki: true -> false"] }] }, + "check", + "drift", + ); + actionsIo.summary("## second block"); + expect(readFileSync(file, "utf8")).toBe( + [ + "## github-settings-as-code (check)", + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| repository | :warning: drift | has_wiki: true -> false |", + "## second block", + "", + ].join("\n"), + ); + })); - test("summary appends each block with one trailing newline, and skips when the runner file is unset", () => { - const file = join(scratch(), "summary.md"); - delete process.env.GITHUB_STEP_SUMMARY; - actionsIo.summary("dropped"); - process.env.GITHUB_STEP_SUMMARY = file; - writeSummary( - actionsIo, - { outcomes: [{ key: "repository", status: "drift", detail: ["has_wiki: true -> false"] }] }, - "check", - "drift", - ); - actionsIo.summary("## second block"); - expect(readFileSync(file, "utf8")).toBe( - [ - "## github-settings-as-code (check)", - "", - "| Section | Status | Detail |", - "|---|---|---|", - "| repository | :warning: drift | has_wiki: true -> false |", - "## second block", - "", - ].join("\n"), - ); - }); - - test("output writes the runner's output file only when it is set", () => { - // The runner creates the file; @actions/core refuses to append to a missing one. - const file = join(scratch(), "output.txt"); - writeFileSync(file, ""); - delete process.env.GITHUB_OUTPUT; - actionsIo.output("result", "dropped"); - // @ts-expect-error a misspelled output name fails to compile at the port - actionsIo.output("reslut", "dropped"); - process.env.GITHUB_OUTPUT = file; - actionsIo.output("result", "clean"); - // @actions/core writes outputs in heredoc form: name< + withTempDir("sac-io-", (dir) => { + // The runner creates the file; @actions/core refuses to append to a missing one. + const file = join(dir, "output.txt"); + writeFileSync(file, ""); + delete process.env.GITHUB_OUTPUT; + actionsIo.output("result", "dropped"); + // @ts-expect-error a misspelled output name fails to compile at the port + actionsIo.output("reslut", "dropped"); + process.env.GITHUB_OUTPUT = file; + actionsIo.output("result", "clean"); + // @actions/core writes outputs in heredoc form: name< { actionsIo.mask("o/private"); diff --git a/test/action/run.test.ts b/test/action/run.test.ts index 1ccf915f..d688389b 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Decrypter, generateX25519Identity, identityToRecipient } from "age-encryption"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; @@ -10,6 +9,8 @@ import type { ArtifactUploader } from "../../src/report/artifact-report.js"; import { REPORT_HEADING } from "../../src/report/composer.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; // A capturing Io replaces the @actions/core sink, so a green suite prints no raw workflow commands and the failure-path tests assert the exact // captured text. @@ -542,17 +543,9 @@ describe("run in mode: merge", () => { "GITHUB_REPOSITORY", ]; const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); - const FIXTURES = join(import.meta.dir, "..", "fixtures", "layers"); + const FIXTURES = join(ROOT, "test", "fixtures", "layers"); const layer = (name: string) => join(FIXTURES, name); - let dir: string; - let mergedFile: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "merge-mode-")); - mergedFile = join(dir, "out", "merged.yml"); - }); afterEach(() => { - rmSync(dir, { recursive: true, force: true }); for (const [key, value] of saved) { if (value === undefined) { delete process.env[key]; @@ -562,8 +555,13 @@ describe("run in mode: merge", () => { } }); - /** A merge run's env: NO token anywhere, the layers low to high, the output path. */ - function setMergeEnv(layers: string[], inputs: { layering?: "merge" | "replace" } = {}) { + /** A merge run's env: NO token anywhere, the layers low to high, the output path under `dir`, which is returned. */ + function setMergeEnv( + dir: string, + layers: string[], + inputs: { layering?: "merge" | "replace" } = {}, + ): string { + const mergedFile = join(dir, "out", "merged.yml"); for (const key of ENV_KEYS) { delete process.env[key]; } @@ -573,10 +571,11 @@ describe("run in mode: merge", () => { if (inputs.layering) { process.env.INPUT_LAYERING = inputs.layering; } + return mergedFile; } /** Write a layer document into the temp dir and return its path. */ - function tempLayer(name: string, doc: unknown): string { + function tempLayer(dir: string, name: string, doc: unknown): string { const path = join(dir, name); writeFileSync(path, stringifyYaml(doc)); return path; @@ -606,120 +605,132 @@ describe("run in mode: merge", () => { }, }; - test("three layers fold into the merged file with no token and no API call; nulls opt out with notices", async () => { - const layers = [layer("fleet.yml"), layer("team.yml"), layer("repo.yml")]; - setMergeEnv(layers); - const api = new MockApi({}); - expect(await run({ api, io: testIo })).toBe(0); - expect(api.calls).toEqual([]); - expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual(THREE_LAYERS_MERGED); - expect(outputs).toEqual({ result: "merged", "skipped-sections": "", "repos-result": "{}" }); - expect(captured).toEqual([ - `notice: ${layer("team.yml")}: null removed repository.has_projects declared by a lower layer`, - `notice: ${layer("repo.yml")}: null removed pages declared by a lower layer`, - `merged 3 layer(s) into ${mergedFile}`, - "result: merged", - ]); - expect(summaries).toEqual([ - [ - "## github-settings-as-code (merge)", - "", - "| Layer | Settings file |", - "|---|---|", - `| 1 | ${layer("fleet.yml")} |`, - `| 2 | ${layer("team.yml")} |`, - `| 3 | ${layer("repo.yml")} |`, - "", - `Merged document written to ${mergedFile}.`, - ].join("\n"), - ]); - }); - - test("a layer that is invalid on its own fails the run naming the layer, before any merge or write", async () => { - const broken = tempLayer("broken.yml", { labels: [{ color: "ffffff" }] }); - setMergeEnv([layer("fleet.yml"), broken]); - const api = new MockApi({}); - expect(await run({ api, io: testIo })).toBe(1); - expect(api.calls).toEqual([]); - expect(existsSync(mergedFile)).toBe(false); - expect(outputs).toEqual({ result: "failed", "skipped-sections": "", "repos-result": "{}" }); - const errors = captured.filter((line) => line.startsWith("error: ")); - expect(errors).toHaveLength(1); - expect(errors[0]).toStartWith( - `error: ${broken} has malformed section entries: labels[0].name:`, - ); - }); - - test("an unreadable layer fails the run naming the path and the input", async () => { - const missing = join(dir, "nope.yml"); - setMergeEnv([layer("fleet.yml"), missing]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); - expect(captured.filter((line) => line.startsWith("error: "))).toEqual([ - `error: cannot read the settings layer ${missing}: Error: ENOENT: no such file or directory, open '${missing}'. Check that every path in the "settings-file" input exists and is valid YAML`, - ]); - }); + test("three layers fold into the merged file with no token and no API call; nulls opt out with notices", () => + withTempDir("merge-mode-", async (dir) => { + const layers = [layer("fleet.yml"), layer("team.yml"), layer("repo.yml")]; + const mergedFile = setMergeEnv(dir, layers); + const api = new MockApi({}); + expect(await run({ api, io: testIo })).toBe(0); + expect(api.calls).toEqual([]); + expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual(THREE_LAYERS_MERGED); + expect(outputs).toEqual({ result: "merged", "skipped-sections": "", "repos-result": "{}" }); + expect(captured).toEqual([ + `notice: ${layer("team.yml")}: null removed repository.has_projects declared by a lower layer`, + `notice: ${layer("repo.yml")}: null removed pages declared by a lower layer`, + `merged 3 layer(s) into ${mergedFile}`, + "result: merged", + ]); + expect(summaries).toEqual([ + [ + "## github-settings-as-code (merge)", + "", + "| Layer | Settings file |", + "|---|---|", + `| 1 | ${layer("fleet.yml")} |`, + `| 2 | ${layer("team.yml")} |`, + `| 3 | ${layer("repo.yml")} |`, + "", + `Merged document written to ${mergedFile}.`, + ].join("\n"), + ]); + })); - test("layering: replace lets the higher layer's keyed lists win while mappings still merge", async () => { - setMergeEnv([layer("fleet.yml"), layer("repo.yml")], { layering: "replace" }); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); - expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ - repository: { has_wiki: false, has_projects: false, description: "mine" }, - labels: { _undeclared: "delete", entries: [{ name: "docs", color: "ffffff" }] }, - rulesets: { _undeclared: "keep", entries: [FLEET_RULESET] }, - }); - }); + test("a layer that is invalid on its own fails the run naming the layer, before any merge or write", () => + withTempDir("merge-mode-", async (dir) => { + const broken = tempLayer(dir, "broken.yml", { labels: [{ color: "ffffff" }] }); + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), broken]); + const api = new MockApi({}); + expect(await run({ api, io: testIo })).toBe(1); + expect(api.calls).toEqual([]); + expect(existsSync(mergedFile)).toBe(false); + expect(outputs).toEqual({ result: "failed", "skipped-sections": "", "repos-result": "{}" }); + const errors = captured.filter((line) => line.startsWith("error: ")); + expect(errors).toHaveLength(1); + expect(errors[0]).toStartWith( + `error: ${broken} has malformed section entries: labels[0].name:`, + ); + })); + + test("an unreadable layer fails the run naming the path and the input", () => + withTempDir("merge-mode-", async (dir) => { + const missing = join(dir, "nope.yml"); + setMergeEnv(dir, [layer("fleet.yml"), missing]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); + expect(captured.filter((line) => line.startsWith("error: "))).toEqual([ + `error: cannot read the settings layer ${missing}: Error: ENOENT: no such file or directory, open '${missing}'. Check that every path in the "settings-file" input exists and is valid YAML`, + ]); + })); - test("a wrapper _layering: replace under a merge run replaces that section alone, and never reaches the file", async () => { - const top = tempLayer("top.yml", { - labels: { _layering: "replace", entries: [{ name: "only", color: "000000" }] }, - rulesets: [{ name: "tags", target: "tag" }], - }); - setMergeEnv([layer("fleet.yml"), top]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); - expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ - repository: { has_wiki: false, has_projects: false }, - labels: { _undeclared: "delete", entries: [{ name: "only", color: "000000" }] }, - rulesets: { _undeclared: "keep", entries: [FLEET_RULESET, { name: "tags", target: "tag" }] }, - pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, - }); - }); + test("layering: replace lets the higher layer's keyed lists win while mappings still merge", () => + withTempDir("merge-mode-", async (dir) => { + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), layer("repo.yml")], { + layering: "replace", + }); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); + expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ + repository: { has_wiki: false, has_projects: false, description: "mine" }, + labels: { _undeclared: "delete", entries: [{ name: "docs", color: "ffffff" }] }, + rulesets: { _undeclared: "keep", entries: [FLEET_RULESET] }, + }); + })); - test("a top-level _layering: replace governs every keyed section of its layer and is consumed", async () => { - const top = tempLayer("top.yml", { - _layering: "replace", - labels: [{ name: "only", color: "000000" }], - rulesets: [{ name: "main", rules: [{ type: "non_fast_forward" }] }], - }); - setMergeEnv([layer("fleet.yml"), top]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); - expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ - repository: { has_wiki: false, has_projects: false }, - labels: { _undeclared: "delete", entries: [{ name: "only", color: "000000" }] }, - rulesets: { - _undeclared: "keep", - entries: [{ name: "main", rules: [{ type: "non_fast_forward" }] }], - }, - pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, - }); - }); + test("a wrapper _layering: replace under a merge run replaces that section alone, and never reaches the file", () => + withTempDir("merge-mode-", async (dir) => { + const top = tempLayer(dir, "top.yml", { + labels: { _layering: "replace", entries: [{ name: "only", color: "000000" }] }, + rulesets: [{ name: "tags", target: "tag" }], + }); + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), top]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); + expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ + repository: { has_wiki: false, has_projects: false }, + labels: { _undeclared: "delete", entries: [{ name: "only", color: "000000" }] }, + rulesets: { + _undeclared: "keep", + entries: [FLEET_RULESET, { name: "tags", target: "tag" }], + }, + pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, + }); + })); + + test("a top-level _layering: replace governs every keyed section of its layer and is consumed", () => + withTempDir("merge-mode-", async (dir) => { + const top = tempLayer(dir, "top.yml", { + _layering: "replace", + labels: [{ name: "only", color: "000000" }], + rulesets: [{ name: "main", rules: [{ type: "non_fast_forward" }] }], + }); + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), top]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); + expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ + repository: { has_wiki: false, has_projects: false }, + labels: { _undeclared: "delete", entries: [{ name: "only", color: "000000" }] }, + rulesets: { + _undeclared: "keep", + entries: [{ name: "main", rules: [{ type: "non_fast_forward" }] }], + }, + pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, + }); + })); - test("a null inside a keyed entry is a merge marker too, announced against its layer", async () => { - const bypass = [{ actor_id: 1, actor_type: "Team", bypass_mode: "always" }]; - const fleet = tempLayer("fleet.yml", { - rulesets: [{ ...FLEET_RULESET, bypass_actors: bypass }], - }); - const top = tempLayer("top.yml", { rulesets: [{ name: "main", bypass_actors: null }] }); - setMergeEnv([fleet, top]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); - expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ - rulesets: { _undeclared: "keep", entries: [FLEET_RULESET] }, - }); - expect(captured).toEqual([ - `notice: ${top}: null removed rulesets[0].bypass_actors declared by a lower layer`, - `merged 2 layer(s) into ${mergedFile}`, - "result: merged", - ]); - }); + test("a null inside a keyed entry is a merge marker too, announced against its layer", () => + withTempDir("merge-mode-", async (dir) => { + const bypass = [{ actor_id: 1, actor_type: "Team", bypass_mode: "always" }]; + const fleet = tempLayer(dir, "fleet.yml", { + rulesets: [{ ...FLEET_RULESET, bypass_actors: bypass }], + }); + const top = tempLayer(dir, "top.yml", { rulesets: [{ name: "main", bypass_actors: null }] }); + const mergedFile = setMergeEnv(dir, [fleet, top]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); + expect(parseYaml(readFileSync(mergedFile, "utf8"))).toEqual({ + rulesets: { _undeclared: "keep", entries: [FLEET_RULESET] }, + }); + expect(captured).toEqual([ + `notice: ${top}: null removed rulesets[0].bypass_actors declared by a lower layer`, + `merged 2 layer(s) into ${mergedFile}`, + "result: merged", + ]); + })); test.each([ ["a mapping", { setting: true }], @@ -727,83 +738,95 @@ describe("run in mode: merge", () => { ["null", null], ])( "a layer with an unknown top-level key set to %s fails naming the layer: a merge has no allowlist to tolerate it", - async (_case, value) => { - const top = tempLayer("top.yml", { - future: value, - rulesets: [{ name: "tags", target: "tag" }], - }); - setMergeEnv([layer("fleet.yml"), top]); + (_case, value) => + withTempDir("merge-mode-", async (dir) => { + const top = tempLayer(dir, "top.yml", { + future: value, + rulesets: [{ name: "tags", target: "tag" }], + }); + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), top]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); + expect(existsSync(mergedFile)).toBe(false); + expect(captured).toEqual([ + `error: unknown top-level section(s) in ${top}: future (known: ${SECTION_KEYS.join(", ")}). Fix the typo, or prefix private keys with "_", or set the "sections" input to limit processing`, + "result: failed", + ]); + }), + ); + + test("a sections allowlist is rejected before any layer is read, so the written document is never narrower than the fold", () => + withTempDir("merge-mode-", async (dir) => { + // An allowlist naming one of the three folded sections is refused up front rather than narrowing the file; the nonexistent layer path proves the + // refusal precedes the read. + const mergedFile = setMergeEnv(dir, [ + layer("fleet.yml"), + layer("repo.yml"), + join(dir, "nope.yml"), + ]); + process.env.INPUT_SECTIONS = "labels"; expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); expect(existsSync(mergedFile)).toBe(false); expect(captured).toEqual([ - `error: unknown top-level section(s) in ${top}: future (known: ${SECTION_KEYS.join(", ")}). Fix the typo, or prefix private keys with "_", or set the "sections" input to limit processing`, + 'error: the "sections" input(s) do not apply to mode: merge, which only folds the ' + + "settings-file layers into merged-file: it never targets a repository, calls the GitHub " + + "API, delivers a report, or narrows the sections it writes. Remove the input(s), or move " + + "them to the apply or check step that runs the merged document", "result: failed", ]); - }, - ); - - test("a sections allowlist is rejected before any layer is read, so the written document is never narrower than the fold", async () => { - // An allowlist naming one of the three folded sections is refused up front rather than narrowing the file; the nonexistent layer path proves the - // refusal precedes the read. - setMergeEnv([layer("fleet.yml"), layer("repo.yml"), join(dir, "nope.yml")]); - process.env.INPUT_SECTIONS = "labels"; - expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); - expect(existsSync(mergedFile)).toBe(false); - expect(captured).toEqual([ - 'error: the "sections" input(s) do not apply to mode: merge, which only folds the ' + - "settings-file layers into merged-file: it never targets a repository, calls the GitHub " + - "API, delivers a report, or narrows the sections it writes. Remove the input(s), or move " + - "them to the apply or check step that runs the merged document", - "result: failed", - ]); - }); - - test("a cyclic layer (a YAML anchor that includes itself) is refused by the fold, naming the layer", async () => { - // A self-referencing anchor under a private key parses to a cyclic object; standalone validation ignores the key, so the engine's boundary is - // what refuses the layer. - const top = join(dir, "top.yml"); - writeFileSync(top, ["_notes: &loop", " self: *loop", "labels: []", ""].join("\n")); - setMergeEnv([layer("fleet.yml"), top]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); - expect(existsSync(mergedFile)).toBe(false); - expect(captured).toEqual([ - `error: layer "${top}": the document contains a reference cycle (a YAML anchor that includes itself); layers must be trees`, - "result: failed", - ]); - }); - - test("merge then apply: the written file applies exactly like the expected merged document", async () => { - setMergeEnv([layer("fleet.yml"), layer("team.yml"), layer("repo.yml")]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); - const expectedFile = tempLayer("expected.yml", THREE_LAYERS_MERGED); - - const applyRun = async (settingsFile: string): Promise => { - for (const key of ENV_KEYS) { - delete process.env[key]; - } - process.env.INPUT_TOKEN = "t"; - process.env.INPUT_MODE = "apply"; - process.env.INPUT_REPOSITORY = "o/r"; - process.env.GITHUB_REPOSITORY = "o/r"; - process.env.INPUT_SECTIONS = "repository,labels"; - process.env["INPUT_SETTINGS-FILE"] = settingsFile; - const api = new MockApi({ - "GET /repos/o/r": { data: { has_wiki: true, description: "old" } }, - "GET /repos/o/r/labels?per_page=100&page=1": { data: [] }, - }).allowMutations("PATCH /repos/o/r", "POST /repos/o/r/labels"); - expect(await run({ api, io: testIo })).toBe(0); - return api; - }; - const fromMerged = await applyRun(mergedFile); - const fromExpected = await applyRun(expectedFile); - expect(fromMerged.mutations()).toEqual(fromExpected.mutations()); - expect(fromMerged.mutations().map((m) => `${m.method} ${m.path}`)).toEqual([ - "PATCH /repos/o/r", - "POST /repos/o/r/labels", - "POST /repos/o/r/labels", - "POST /repos/o/r/labels", - ]); - }); + })); + + test("a cyclic layer (a YAML anchor that includes itself) is refused by the fold, naming the layer", () => + withTempDir("merge-mode-", async (dir) => { + // A self-referencing anchor under a private key parses to a cyclic object; standalone validation ignores the key, so the engine's boundary is + // what refuses the layer. + const top = join(dir, "top.yml"); + writeFileSync(top, ["_notes: &loop", " self: *loop", "labels: []", ""].join("\n")); + const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), top]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); + expect(existsSync(mergedFile)).toBe(false); + expect(captured).toEqual([ + `error: layer "${top}": the document contains a reference cycle (a YAML anchor that includes itself); layers must be trees`, + "result: failed", + ]); + })); + + test("merge then apply: the written file applies exactly like the expected merged document", () => + withTempDir("merge-mode-", async (dir) => { + const mergedFile = setMergeEnv(dir, [ + layer("fleet.yml"), + layer("team.yml"), + layer("repo.yml"), + ]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(0); + const expectedFile = tempLayer(dir, "expected.yml", THREE_LAYERS_MERGED); + + const applyRun = async (settingsFile: string): Promise => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.INPUT_TOKEN = "t"; + process.env.INPUT_MODE = "apply"; + process.env.INPUT_REPOSITORY = "o/r"; + process.env.GITHUB_REPOSITORY = "o/r"; + process.env.INPUT_SECTIONS = "repository,labels"; + process.env["INPUT_SETTINGS-FILE"] = settingsFile; + const api = new MockApi({ + "GET /repos/o/r": { data: { has_wiki: true, description: "old" } }, + "GET /repos/o/r/labels?per_page=100&page=1": { data: [] }, + }).allowMutations("PATCH /repos/o/r", "POST /repos/o/r/labels"); + expect(await run({ api, io: testIo })).toBe(0); + return api; + }; + const fromMerged = await applyRun(mergedFile); + const fromExpected = await applyRun(expectedFile); + expect(fromMerged.mutations()).toEqual(fromExpected.mutations()); + expect(fromMerged.mutations().map((m) => `${m.method} ${m.path}`)).toEqual([ + "PATCH /repos/o/r", + "POST /repos/o/r/labels", + "POST /repos/o/r/labels", + "POST /repos/o/r/labels", + ]); + })); }); describe("run in mode: snapshot", () => { @@ -817,15 +840,7 @@ describe("run in mode: snapshot", () => { "GITHUB_REPOSITORY", ]; const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); - let dir: string; - let snapshotFile: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "snapshot-mode-")); - snapshotFile = join(dir, "out", "snapshot.yml"); - }); afterEach(() => { - rmSync(dir, { recursive: true, force: true }); for (const [key, value] of saved) { if (value === undefined) { delete process.env[key]; @@ -835,8 +850,9 @@ describe("run in mode: snapshot", () => { } }); - /** A snapshot run's env: the token, the target, the file, the labels allowlist. */ - function setSnapshotEnv() { + /** A snapshot run's env: the token, the target, the file under `dir` (returned), the labels allowlist. */ + function setSnapshotEnv(dir: string): string { + const snapshotFile = join(dir, "out", "snapshot.yml"); for (const key of ENV_KEYS) { delete process.env[key]; } @@ -848,55 +864,59 @@ describe("run in mode: snapshot", () => { process.env.INPUT_REPOSITORY = "o/r"; process.env["INPUT_SNAPSHOT-FILE"] = snapshotFile; process.env.INPUT_SECTIONS = "labels"; + return snapshotFile; } const LABELS = [{ name: "bug", color: "d73a4a", description: "Something is broken" }]; const labelsApi = () => new MockApi({ "GET /repos/o/r/labels?per_page=100&page=1": { data: LABELS } }); - test("writes the live settings to snapshot-file and publishes the snapshot result", async () => { - setSnapshotEnv(); - const api = labelsApi(); - expect(await run({ api, io: testIo })).toBe(0); - expect(api.mutations()).toEqual([]); - expect(parseYaml(readFileSync(snapshotFile, "utf8"))).toEqual({ - labels: { _undeclared: "delete", entries: LABELS }, - }); - expect(outputs).toEqual({ result: "snapshot", "skipped-sections": "", "repos-result": "{}" }); - expect(captured).toEqual([`snapshot written to ${snapshotFile}`, "result: snapshot"]); - }); - - test("an apply-time input is rejected before any API call, and no file is written", async () => { - setSnapshotEnv(); - process.env["INPUT_SETTINGS-FILE"] = "other.yml"; - const api = labelsApi(); - expect(await run({ api, io: testIo })).toBe(1); - expect(api.calls).toEqual([]); - expect(existsSync(snapshotFile)).toBe(false); - expect(captured).toEqual([ - 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the ' + - "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + - "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + - "the apply, check, or merge step they belong to", - "result: failed", - ]); - }); + test("writes the live settings to snapshot-file and publishes the snapshot result", () => + withTempDir("snapshot-mode-", async (dir) => { + const snapshotFile = setSnapshotEnv(dir); + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(0); + expect(api.mutations()).toEqual([]); + expect(parseYaml(readFileSync(snapshotFile, "utf8"))).toEqual({ + labels: { _undeclared: "delete", entries: LABELS }, + }); + expect(outputs).toEqual({ result: "snapshot", "skipped-sections": "", "repos-result": "{}" }); + expect(captured).toEqual([`snapshot written to ${snapshotFile}`, "result: snapshot"]); + })); + + test("an apply-time input is rejected before any API call, and no file is written", () => + withTempDir("snapshot-mode-", async (dir) => { + const snapshotFile = setSnapshotEnv(dir); + process.env["INPUT_SETTINGS-FILE"] = "other.yml"; + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(1); + expect(api.calls).toEqual([]); + expect(existsSync(snapshotFile)).toBe(false); + expect(captured).toEqual([ + 'error: the "settings-file" input(s) do not apply to mode: snapshot, which only reads the ' + + "target repositories' live settings into snapshot-file or snapshot-dir: it applies no " + + "document, folds no layers, and delivers no report. Remove the input(s), or move them to " + + "the apply, check, or merge step they belong to", + "result: failed", + ]); + })); - test("snapshot then check: the written file checks clean against the live state it was read from", async () => { - setSnapshotEnv(); - expect(await run({ api: labelsApi(), io: testIo })).toBe(0); - for (const key of ENV_KEYS) { - delete process.env[key]; - } - process.env.INPUT_TOKEN = "t"; - process.env.INPUT_MODE = "check"; - process.env.GITHUB_REPOSITORY = "o/r"; - process.env.INPUT_REPOSITORY = "o/r"; - process.env.INPUT_SECTIONS = "labels"; - process.env["INPUT_SETTINGS-FILE"] = snapshotFile; - const api = labelsApi(); - expect(await run({ api, io: testIo })).toBe(0); - expect(api.mutations()).toEqual([]); - expect(outputs.result).toBe("clean"); - }); + test("snapshot then check: the written file checks clean against the live state it was read from", () => + withTempDir("snapshot-mode-", async (dir) => { + const snapshotFile = setSnapshotEnv(dir); + expect(await run({ api: labelsApi(), io: testIo })).toBe(0); + for (const key of ENV_KEYS) { + delete process.env[key]; + } + process.env.INPUT_TOKEN = "t"; + process.env.INPUT_MODE = "check"; + process.env.GITHUB_REPOSITORY = "o/r"; + process.env.INPUT_REPOSITORY = "o/r"; + process.env.INPUT_SECTIONS = "labels"; + process.env["INPUT_SETTINGS-FILE"] = snapshotFile; + const api = labelsApi(); + expect(await run({ api, io: testIo })).toBe(0); + expect(api.mutations()).toEqual([]); + expect(outputs.result).toBe("clean"); + })); }); diff --git a/test/architecture/architecture.test.ts b/test/architecture/architecture.test.ts index 314f4437..ff6d03c0 100644 --- a/test/architecture/architecture.test.ts +++ b/test/architecture/architecture.test.ts @@ -4,7 +4,6 @@ */ import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; import { ARCHITECTURE_PATH, importSpecifiers, @@ -12,8 +11,7 @@ import { readArchitecture, renderArchitectureMermaid, } from "../../.github/scripts/arch-lint.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; describe("architecture.yml against src/", () => { const arch = readArchitecture(ROOT); diff --git a/test/cli/execution.test.ts b/test/cli/execution.test.ts index 03113a70..769a73fd 100644 --- a/test/cli/execution.test.ts +++ b/test/cli/execution.test.ts @@ -4,9 +4,8 @@ * same set spelled as flags, an equality test/cli/inputs.test.ts pins on its own. */ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { run } from "../../src/action/run.js"; import { @@ -18,6 +17,8 @@ import { parseConfig, } from "../../src/index.js"; import { MockApi, type Route } from "../mock-api.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; import { runCli } from "./streams.js"; type Inputs = Partial>; @@ -36,7 +37,6 @@ interface Case { readonly ends: { readonly code: number; readonly result: string }; } -const ROOT = join(import.meta.dir, "..", ".."); const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); const LAYERS = join(ROOT, "test", "fixtures", "layers"); const TOKEN = "ghp_equivalence_token"; @@ -234,19 +234,6 @@ const OUTPUT_LINE = new RegExp(`^(${OUTPUT_NAMES.join("|")})=(.*)$`); const CONTEXT_KEYS = ["GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "GITHUB_RUN_ID", "GITHUB_TOKEN"]; const INPUT_KEYS = Object.keys(INPUT_DECLS).map((name) => `INPUT_${name.toUpperCase()}`); -const scratch: string[] = []; -afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function tempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "gsac-equivalence-")); - scratch.push(dir); - return dir; -} - function api(c: Case): MockApi { return new MockApi({ ...c.routes }).allowMutations(...(c.mutations ?? [])); } @@ -256,8 +243,8 @@ const fold = (dir: string) => (line: string) => line.replaceAll(dir, ""); /** The two faces write their snapshots at different instants; the header line is the one byte range that may differ. */ const SNAPSHOT_INSTANT = /^(# Snapshot of \S+ taken )\S+$/m; -function scratchFor(c: Case): string { - const dir = tempDir(); +/** `dir` holding the case's settings file, when it has one. */ +function scratchFor(dir: string, c: Case): string { if (c.settings !== undefined) { writeFileSync(join(dir, "settings.yml"), c.settings); } @@ -276,77 +263,81 @@ function writtenFiles(dir: string): Record { } /** The action's face: the inputs as the runner's INPUT_* variables, the run over a collecting Io. */ -async function throughEnv(c: Case): Promise { - const dir = scratchFor(c); - const inputs = c.inputs(dir); - const keys = [...INPUT_KEYS, ...CONTEXT_KEYS]; - const saved = new Map(keys.map((key) => [key, process.env[key]])); - for (const key of keys) { - delete process.env[key]; - } - for (const [name, value] of Object.entries(inputs)) { - process.env[`INPUT_${name.toUpperCase()}`] = value; - } - for (const [key, value] of Object.entries(c.env)) { - process.env[key] = value; - } - const collected = collectingIo(); - let code: number; - try { - code = await run({ api: api(c), io: collected.io }); - } finally { - for (const [key, value] of saved) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; +function throughEnv(c: Case): Promise { + return withTempDir("gsac-equivalence-", async (dir) => { + scratchFor(dir, c); + const inputs = c.inputs(dir); + const keys = [...INPUT_KEYS, ...CONTEXT_KEYS]; + const saved = new Map(keys.map((key) => [key, process.env[key]])); + for (const key of keys) { + delete process.env[key]; + } + for (const [name, value] of Object.entries(inputs)) { + process.env[`INPUT_${name.toUpperCase()}`] = value; + } + for (const [key, value] of Object.entries(c.env)) { + process.env[key] = value; + } + const collected = collectingIo(); + let code: number; + try { + code = await run({ api: api(c), io: collected.io }); + } finally { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } } } - } - const at = fold(dir); - return { - code, - outputs: Object.fromEntries( - Object.entries(collected.outputs).map(([name, value]) => [name, at(value)]), - ), - logs: collected.lines.filter((l) => l.level === undefined).map((l) => at(l.line)), - annotations: collected.lines - .filter((l) => l.level !== undefined) - .map((l) => at(`${l.level}: ${l.line}`)), - files: writtenFiles(dir), - }; + const at = fold(dir); + return { + code, + outputs: Object.fromEntries( + Object.entries(collected.outputs).map(([name, value]) => [name, at(value)]), + ), + logs: collected.lines.filter((l) => l.level === undefined).map((l) => at(l.line)), + annotations: collected.lines + .filter((l) => l.level !== undefined) + .map((l) => at(`${l.level}: ${l.line}`)), + files: writtenFiles(dir), + }; + }); } /** The CLI's face: the same inputs as the subcommand's flags, the run over the program's streams. */ -async function throughArgv(c: Case): Promise { - const dir = scratchFor(c); - const { mode, ...flags } = c.inputs(dir); - const argv = [ - mode ?? "", - ...Object.entries(flags).flatMap(([name, value]) => [`--${name}`, value ?? ""]), - ]; - const result = await runCli(argv, api(c), c.env); - const at = fold(dir); - const outputs: Partial> = {}; - const logs: string[] = []; - for (const line of result.stdout.split("\n").filter((l) => l !== "")) { - const output = OUTPUT_LINE.exec(line); - if (output === null) { - logs.push(at(line)); - } else { - outputs[output[1] as OutputName] = at(output[2] ?? ""); +function throughArgv(c: Case): Promise { + return withTempDir("gsac-equivalence-", async (dir) => { + scratchFor(dir, c); + const { mode, ...flags } = c.inputs(dir); + const argv = [ + mode ?? "", + ...Object.entries(flags).flatMap(([name, value]) => [`--${name}`, value ?? ""]), + ]; + const result = await runCli(argv, api(c), c.env); + const at = fold(dir); + const outputs: Partial> = {}; + const logs: string[] = []; + for (const line of result.stdout.split("\n").filter((l) => l !== "")) { + const output = OUTPUT_LINE.exec(line); + if (output === null) { + logs.push(at(line)); + } else { + outputs[output[1] as OutputName] = at(output[2] ?? ""); + } } - } - return { - code: result.code, - outputs, - logs, - annotations: result.stderr - .split("\n") - .filter((l) => l !== "") - .map(at), - files: writtenFiles(dir), - }; + return { + code: result.code, + outputs, + logs, + annotations: result.stderr + .split("\n") + .filter((l) => l !== "") + .map(at), + files: writtenFiles(dir), + }; + }); } describe("the action and the CLI run one arm to one result", () => { diff --git a/test/cli/init.test.ts b/test/cli/init.test.ts index 6f57362b..39955bfe 100644 --- a/test/cli/init.test.ts +++ b/test/cli/init.test.ts @@ -1,8 +1,7 @@ /** `init` end to end through main() against a stub client, the argv port and the written file alike. */ -import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import type { InitConfig } from "../../src/cli/init.js"; @@ -20,6 +19,7 @@ import { validateSettings, } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream, runCli } from "./streams.js"; const TOKEN = "ghp_cli_init_token"; @@ -28,19 +28,6 @@ const LABELS_ROUTE = { "GET /repos/o/r/labels?per_page=100&page=1": { data: [BUG const LABELS_DOC = { labels: { _undeclared: "delete", entries: [BUG] } }; const LABELS_GRANT = sectionGrant(sectionModule("labels")); -const scratch: string[] = []; -afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function tempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "gsac-init-")); - scratch.push(dir); - return dir; -} - const cli = runCli; /** Drive init on `argv` and capture what its argv port parsed, or what it printed. */ @@ -158,51 +145,93 @@ describe("init: argv -> config", () => { }); describe("init: the written file and the printed grant", () => { - test("writes the snapshot under the schema hint, prints the grant for its sections, exits 0", async () => { - const file = join(tempDir(), ".github", "settings.yml"); - const api = new MockApi(LABELS_ROUTE); - const result = await cli( - [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", + test("writes the snapshot under the schema hint, prints the grant for its sections, exits 0", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, ".github", "settings.yml"); + const api = new MockApi(LABELS_ROUTE); + const result = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels", + ], + api, + ); + expect(result).toEqual({ + code: 0, + stdout: [ + `${file} written from o/r: 1 section(s) declared (labels)`, + "Token permissions the file needs:", + ` labels: ${LABELS_GRANT}`, + "", + ].join("\n"), + stderr: "", + }); + expect(api.mutations()).toEqual([]); + const written = readFileSync(file, "utf8"); + // The first line is the editor's schema hint, the same URL the README's quick start pins. + expect(written.split("\n")[0]).toBe(`# yaml-language-server: $schema=${SNAPSHOT_SCHEMA_URL}`); + expect(written.split("\n")[1]).toStartWith("# Snapshot of o/r taken "); + const doc = parseYaml(written); + expect(doc).toEqual(LABELS_DOC); + expect(validateSettings(doc).isOk()).toBe(true); + })); + + test("the grant is the section declarations' for exactly the sections the file declares", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const api = new MockApi({ + ...LABELS_ROUTE, + "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { data: [] }, + }); + const result = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels,milestones", + "--json", + ], + api, + ); + expect(result.code).toBe(0); + // Milestones: nothing live, so the section is omitted and earns no grant line. + const declared = Object.keys(parseYaml(readFileSync(file, "utf8"))); + expect(declared).toEqual(["labels"]); + expect(JSON.parse(result.stdout)).toEqual({ + result: "snapshot", file, - "--sections", - "labels", - ], - api, - ); - expect(result).toEqual({ - code: 0, - stdout: [ - `${file} written from o/r: 1 section(s) declared (labels)`, - "Token permissions the file needs:", - ` labels: ${LABELS_GRANT}`, - "", - ].join("\n"), - stderr: "", - }); - expect(api.mutations()).toEqual([]); - const written = readFileSync(file, "utf8"); - // The first line is the editor's schema hint, the same URL the README's quick start pins. - expect(written.split("\n")[0]).toBe(`# yaml-language-server: $schema=${SNAPSHOT_SCHEMA_URL}`); - expect(written.split("\n")[1]).toStartWith("# Snapshot of o/r taken "); - const doc = parseYaml(written); - expect(doc).toEqual(LABELS_DOC); - expect(validateSettings(doc).isOk()).toBe(true); - }); + repository: "o/r", + "skipped-sections": [], + grant: Object.fromEntries( + declared.map((key) => [key, sectionGrant(sectionModule(key as "labels"))]), + ), + }); + })); - test("the grant is the section declarations' for exactly the sections the file declares", async () => { - const file = join(tempDir(), "settings.yml"); - const api = new MockApi({ - ...LABELS_ROUTE, - "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { data: [] }, - }); - const result = await cli( - [ + test("a section failing beside one that read back fails init: no file, the errors name the section", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const api = new MockApi({ + "GET /repos/o/r/labels?per_page=100&page=1": { + error: { status: 500, message: "Server Error", body: "" }, + }, + "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { + data: [{ number: 1, title: "v1", state: "open", description: null, due_on: null }], + }, + }); + const args = [ "init", "--token", TOKEN, @@ -212,161 +241,125 @@ describe("init: the written file and the printed grant", () => { file, "--sections", "labels,milestones", - "--json", - ], - api, - ); - expect(result.code).toBe(0); - // Milestones: nothing live, so the section is omitted and earns no grant line. - const declared = Object.keys(parseYaml(readFileSync(file, "utf8"))); - expect(declared).toEqual(["labels"]); - expect(JSON.parse(result.stdout)).toEqual({ - result: "snapshot", - file, - repository: "o/r", - "skipped-sections": [], - grant: Object.fromEntries( - declared.map((key) => [key, sectionGrant(sectionModule(key as "labels"))]), - ), - }); - }); + ]; + const plain = await cli(args, api); + expect(plain.code).toBe(1); + expect(plain.stdout).toBe(""); + expect(plain.stderr).toMatch(/^error: labels: /); + const problem = `the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix`; + expect(plain.stderr).toEndWith(`error: ${problem}\n`); + expect(existsSync(file)).toBe(false); + const json = await cli([...args, "--json"], api); + expect(json.code).toBe(1); + expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); + })); - test("a section failing beside one that read back fails init: no file, the errors name the section", async () => { - const file = join(tempDir(), "settings.yml"); - const api = new MockApi({ - "GET /repos/o/r/labels?per_page=100&page=1": { - error: { status: 500, message: "Server Error", body: "" }, - }, - "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { - data: [{ number: 1, title: "v1", state: "open", description: null, due_on: null }], - }, - }); - const args = [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels,milestones", - ]; - const plain = await cli(args, api); - expect(plain.code).toBe(1); - expect(plain.stdout).toBe(""); - expect(plain.stderr).toMatch(/^error: labels: /); - const problem = `the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix`; - expect(plain.stderr).toEndWith(`error: ${problem}\n`); - expect(existsSync(file)).toBe(false); - const json = await cli([...args, "--json"], api); - expect(json.code).toBe(1); - expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); - }); - - test("a check over the written file reads clean against the same repository", async () => { - const file = join(tempDir(), "settings.yml"); - const api = new MockApi(LABELS_ROUTE); - const init = await cli( - [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels", - ], - api, - ); - expect(init.code).toBe(0); - const check = await cli( - [ - "check", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels", - "--private-repos", - "show", - ], - api, - ); - expect(check).toEqual({ - code: 0, - stdout: "result: clean\nresult=clean\nskipped-sections=\nrepos-result={}\n", - stderr: "", - }); - }); + test("a check over the written file reads clean against the same repository", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const api = new MockApi(LABELS_ROUTE); + const init = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels", + ], + api, + ); + expect(init.code).toBe(0); + const check = await cli( + [ + "check", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels", + "--private-repos", + "show", + ], + api, + ); + expect(check).toEqual({ + code: 0, + stdout: "result: clean\nresult=clean\nskipped-sections=\nrepos-result={}\n", + stderr: "", + }); + })); - test("a denied section under warn is skipped: partial, the file omits it, the line says so, exit 0", async () => { - const file = join(tempDir(), "settings.yml"); - // No variables route: the read answers 404, the fine-grained denial. - const result = await cli( - [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels,actions_variables,check_suite_preferences", - "--on-missing-permission", - "warn", - ], - new MockApi(LABELS_ROUTE), - ); - expect(result.code).toBe(0); - expect(result.stderr).toMatch( - /^warning: actions_variables: skipped - the token was denied GET/, - ); - expect(result.stdout).toBe( - [ - `${file} written from o/r: 1 section(s) declared (labels)`, - "not read back: check_suite_preferences (the file's header says why; declare them by hand to manage them)", - "skipped: actions_variables (the file omits them; the warnings above say why)", - "Token permissions the file needs:", - ` labels: ${LABELS_GRANT}`, - "", - ].join("\n"), - ); - expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); - }); + test("a denied section under warn is skipped: partial, the file omits it, the line says so, exit 0", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + // No variables route: the read answers 404, the fine-grained denial. + const result = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels,actions_variables,check_suite_preferences", + "--on-missing-permission", + "warn", + ], + new MockApi(LABELS_ROUTE), + ); + expect(result.code).toBe(0); + expect(result.stderr).toMatch( + /^warning: actions_variables: skipped - the token was denied GET/, + ); + expect(result.stdout).toBe( + [ + `${file} written from o/r: 1 section(s) declared (labels)`, + "not read back: check_suite_preferences (the file's header says why; declare them by hand to manage them)", + "skipped: actions_variables (the file omits them; the warnings above say why)", + "Token permissions the file needs:", + ` labels: ${LABELS_GRANT}`, + "", + ].join("\n"), + ); + expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); + })); - test("a denied section under fail fails the run: no file, the error names the section, exit 1", async () => { - const file = join(tempDir(), "settings.yml"); - const result = await cli( - [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels,actions_variables", - ], - new MockApi(LABELS_ROUTE), - ); - expect(result.code).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toMatch( - /^error: actions_variables: not snapshotted - the token was denied GET/, - ); - expect(result.stderr).toEndWith( - `error: the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix\n`, - ); - expect(existsSync(file)).toBe(false); - }); + test("a denied section under fail fails the run: no file, the error names the section, exit 1", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const result = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels,actions_variables", + ], + new MockApi(LABELS_ROUTE), + ); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch( + /^error: actions_variables: not snapshotted - the token was denied GET/, + ); + expect(result.stderr).toEndWith( + `error: the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix\n`, + ); + expect(existsSync(file)).toBe(false); + })); }); describe("init: an existing settings file", () => { @@ -382,64 +375,67 @@ describe("init: an existing settings file", () => { "labels", ]; - test("is refused naming --force, before any API call, and left as it was", async () => { - const file = join(tempDir(), "settings.yml"); - writeFileSync(file, "repository:\n has_wiki: false\n"); - const api = new MockApi(LABELS_ROUTE); - const result = await cli(target(file), api); - expect(api.calls).toHaveLength(0); - expect(result).toEqual({ - code: 1, - stdout: "", - stderr: `error: ${file} already exists: init writes the starting settings file and does not replace the one you author. Pass --force to replace it, or --settings-file to write elsewhere\n`, - }); - expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); - const json = await cli([...target(file), "--json"], api); - expect(json.code).toBe(1); - expect(JSON.parse(json.stdout)).toEqual({ - result: "failed", - file, - problem: result.stderr.slice("error: ".length, -1), - }); - }); + test("is refused naming --force, before any API call, and left as it was", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync(file, "repository:\n has_wiki: false\n"); + const api = new MockApi(LABELS_ROUTE); + const result = await cli(target(file), api); + expect(api.calls).toHaveLength(0); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: `error: ${file} already exists: init writes the starting settings file and does not replace the one you author. Pass --force to replace it, or --settings-file to write elsewhere\n`, + }); + expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); + const json = await cli([...target(file), "--json"], api); + expect(json.code).toBe(1); + expect(JSON.parse(json.stdout)).toEqual({ + result: "failed", + file, + problem: result.stderr.slice("error: ".length, -1), + }); + })); - test("is left byte for byte as it was when --force gets past the check and a section then fails", async () => { - const file = join(tempDir(), "settings.yml"); - const original = "repository:\n has_wiki: false\n"; - writeFileSync(file, original); - const api = new MockApi({ - "GET /repos/o/r/labels?per_page=100&page=1": { - error: { status: 500, message: "Server Error", body: "" }, - }, - }); - const result = await cli([...target(file), "--force"], api); - expect(result.code).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toMatch(/^error: labels: /); - const problem = `the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix`; - expect(result.stderr).toEndWith(`error: ${problem}\n`); - expect(readFileSync(file, "utf8")).toBe(original); - const json = await cli([...target(file), "--force", "--json"], api); - expect(json.code).toBe(1); - expect(json.stderr).toBe(result.stderr); - expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); - expect(readFileSync(file, "utf8")).toBe(original); - }); + test("is left byte for byte as it was when --force gets past the check and a section then fails", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const original = "repository:\n has_wiki: false\n"; + writeFileSync(file, original); + const api = new MockApi({ + "GET /repos/o/r/labels?per_page=100&page=1": { + error: { status: 500, message: "Server Error", body: "" }, + }, + }); + const result = await cli([...target(file), "--force"], api); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toMatch(/^error: labels: /); + const problem = `the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix`; + expect(result.stderr).toEndWith(`error: ${problem}\n`); + expect(readFileSync(file, "utf8")).toBe(original); + const json = await cli([...target(file), "--force", "--json"], api); + expect(json.code).toBe(1); + expect(json.stderr).toBe(result.stderr); + expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); + expect(readFileSync(file, "utf8")).toBe(original); + })); - test("a problem in the flags names the settings file in the envelope when the command was told one", async () => { - const file = join(tempDir(), "settings.yml"); - const result = await cli( - ["init", "--repository", "o/r", "--settings-file", file, "--json"], - new MockApi({}), - ); - const problem = - "cannot call the GitHub API: no token was provided. Pass --token, or export GITHUB_TOKEN"; - expect(result).toEqual({ - code: 1, - stdout: `${JSON.stringify({ result: "failed", file, problem })}\n`, - stderr: `error: ${problem}\n`, - }); - }); + test("a problem in the flags names the settings file in the envelope when the command was told one", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const result = await cli( + ["init", "--repository", "o/r", "--settings-file", file, "--json"], + new MockApi({}), + ); + const problem = + "cannot call the GitHub API: no token was provided. Pass --token, or export GITHUB_TOKEN"; + expect(result).toEqual({ + code: 1, + stdout: `${JSON.stringify({ result: "failed", file, problem })}\n`, + stderr: `error: ${problem}\n`, + }); + })); const NO_MILESTONES = { "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { data: [] } }; test.each<[string, string, Record, string, number | undefined]>([ @@ -467,38 +463,41 @@ describe("init: an existing settings file", () => { ], ])( "never writes an empty document, --force or not: %s, exit 1", - async (_case, sections, routes, why, apiCalls) => { - const file = join(tempDir(), "settings.yml"); - writeFileSync(file, "repository:\n has_wiki: false\n"); - const api = new MockApi(routes as ConstructorParameters[0]); - const result = await cli([...target(file).slice(0, -1), sections, "--force"], api); - if (apiCalls !== undefined) { - expect(api.calls).toHaveLength(apiCalls); - } - expect(result.code).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toEndWith( - `error: the snapshot of o/r declares no section (${why}), so ${file} was not written. Choose sections init can read back, or drop --sections to read every section\n`, - ); - expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); - }, + (_case, sections, routes, why, apiCalls) => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync(file, "repository:\n has_wiki: false\n"); + const api = new MockApi(routes as ConstructorParameters[0]); + const result = await cli([...target(file).slice(0, -1), sections, "--force"], api); + if (apiCalls !== undefined) { + expect(api.calls).toHaveLength(apiCalls); + } + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toEndWith( + `error: the snapshot of o/r declares no section (${why}), so ${file} was not written. Choose sections init can read back, or drop --sections to read every section\n`, + ); + expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); + }), ); - test("is replaced under --force", async () => { - const file = join(tempDir(), "settings.yml"); - writeFileSync(file, "repository:\n has_wiki: false\n"); - const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); - expect(result.code).toBe(0); - expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); - }); + test("is replaced under --force", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync(file, "repository:\n has_wiki: false\n"); + const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); + expect(result.code).toBe(0); + expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); + })); - test("an unwritable destination fails naming --settings-file", async () => { - // A directory at the path: --force gets past the existence check, the write itself fails. - const file = join(tempDir(), "settings.yml"); - mkdirSync(file); - const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); - expect(result.code).toBe(1); - expect(result.stderr).toStartWith(`error: cannot write the settings file ${file}: `); - expect(result.stderr).toEndWith(". Check that --settings-file names a writable path\n"); - }); + test("an unwritable destination fails naming --settings-file", () => + withTempDir("gsac-init-", async (dir) => { + // A directory at the path: --force gets past the existence check, the write itself fails. + const file = join(dir, "settings.yml"); + mkdirSync(file); + const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith(`error: cannot write the settings file ${file}: `); + expect(result.stderr).toEndWith(". Check that --settings-file names a writable path\n"); + })); }); diff --git a/test/cli/io.test.ts b/test/cli/io.test.ts index 65de7907..f77fdb40 100644 --- a/test/cli/io.test.ts +++ b/test/cli/io.test.ts @@ -4,20 +4,13 @@ * channel goes through (no runner masks for a terminal). */ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { type CliIoOptions, cliIo, maskedStreams } from "../../src/cli/io.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream } from "./streams.js"; -const scratch: string[] = []; -afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - function open(options: Partial> = {}) { const stdout = memoryStream(); const stderr = memoryStream(); @@ -90,50 +83,48 @@ describe("the CLI Io", () => { expect(stderr()).toBe("error: boom\n"); }); - test("a masked value is redacted on every channel, the outputs included", () => { - const dir = mkdtempSync(join(tmpdir(), "gsac-io-")); - scratch.push(dir); - const summary = join(dir, "summary.md"); - const { io, flush, stdout, stderr } = open({ summaryFile: summary, verbose: true }); - io.mask("ghp_secret"); - io.log("token ghp_secret in a log line"); - io.annotate("error", "401 for ghp_secret"); - io.debug("Authorization: token ghp_secret"); - io.summary("# run by ghp_secret"); - io.output("result", "failed ghp_secret"); - flush(); - const everything = stdout() + stderr() + readFileSync(summary, "utf8"); - expect(everything).not.toContain("ghp_secret"); - expect(stdout()).toBe("token *** in a log line\nresult=failed ***\n"); - expect(stderr()).toBe("error: 401 for ***\ndebug: Authorization: token ***\n"); - expect(readFileSync(summary, "utf8")).toBe("# run by ***\n"); - }); + test("a masked value is redacted on every channel, the outputs included", () => + withTempDir("gsac-io-", (dir) => { + const summary = join(dir, "summary.md"); + const { io, flush, stdout, stderr } = open({ summaryFile: summary, verbose: true }); + io.mask("ghp_secret"); + io.log("token ghp_secret in a log line"); + io.annotate("error", "401 for ghp_secret"); + io.debug("Authorization: token ghp_secret"); + io.summary("# run by ghp_secret"); + io.output("result", "failed ghp_secret"); + flush(); + const everything = stdout() + stderr() + readFileSync(summary, "utf8"); + expect(everything).not.toContain("ghp_secret"); + expect(stdout()).toBe("token *** in a log line\nresult=failed ***\n"); + expect(stderr()).toBe("error: 401 for ***\ndebug: Authorization: token ***\n"); + expect(readFileSync(summary, "utf8")).toBe("# run by ***\n"); + })); - test("summary blocks append to the named file and are dropped without one", () => { - const dir = mkdtempSync(join(tmpdir(), "gsac-io-")); - scratch.push(dir); - const summary = join(dir, "summary.md"); - const named = open({ summaryFile: summary }); - named.io.summary("## first"); - named.io.summary("## second"); - expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); - // Without a file the block goes nowhere: no stream carries it, and the working - // directory (where a defaulted path would land) stays empty. - const cwd = mkdtempSync(join(tmpdir(), "gsac-io-cwd-")); - scratch.push(cwd); - const previous = process.cwd(); - process.chdir(cwd); - try { - const unnamed = open(); - unnamed.io.summary("## dropped"); - expect(unnamed.stdout() + unnamed.stderr()).toBe(""); - expect(readdirSync(cwd)).toEqual([]); - } finally { - process.chdir(previous); - } - expect(readdirSync(dir)).toEqual(["summary.md"]); - expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); - }); + test("summary blocks append to the named file and are dropped without one", () => + withTempDir("gsac-io-", async (dir) => { + const summary = join(dir, "summary.md"); + const named = open({ summaryFile: summary }); + named.io.summary("## first"); + named.io.summary("## second"); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + // Without a file the block goes nowhere: no stream carries it, and the working + // directory (where a defaulted path would land) stays empty. + await withTempDir("gsac-io-cwd-", (cwd) => { + const previous = process.cwd(); + process.chdir(cwd); + try { + const unnamed = open(); + unnamed.io.summary("## dropped"); + expect(unnamed.stdout() + unnamed.stderr()).toBe(""); + expect(readdirSync(cwd)).toEqual([]); + } finally { + process.chdir(previous); + } + }); + expect(readdirSync(dir)).toEqual(["summary.md"]); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + })); test("identical consecutive lines are all printed, never folded", () => { // consola folds repeats within a second by default; a drift report with diff --git a/test/cli/program.test.ts b/test/cli/program.test.ts index 3a7029ee..6c6f1e5a 100644 --- a/test/cli/program.test.ts +++ b/test/cli/program.test.ts @@ -5,35 +5,22 @@ * what the CLI prints. */ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import type { CliHost } from "../../src/cli/commands.js"; import { CLI_COMMANDS, main } from "../../src/cli/program.js"; import { type ConfigEnv, type GithubClient, sectionGrant, sectionModule } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream, runCli } from "./streams.js"; -const ROOT = join(import.meta.dir, "..", ".."); const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); const LAYERS = join(ROOT, "test", "fixtures", "layers"); const TOKEN = "ghp_cli_test_token"; -const scratch: string[] = []; -afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function tempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "gsac-cli-")); - scratch.push(dir); - return dir; -} - const cli = runCli; describe("check and apply", () => { @@ -210,90 +197,93 @@ describe("check and apply", () => { test.each([false, true])( "a failure after the parse is reported through the mask boundary, exit 1 (verbose: %p)", - async (verbose) => { - // The summary file's directory does not exist, so the write throws inside - // the run; the path carries the token, and the report must not. Without - // --verbose the remedy asks for it; with it the stack is the report. - const summary = join(tempDir(), "missing", `${TOKEN}.md`); - const masked = summary.replaceAll(TOKEN, "***"); - const result = await cli( - ["check", ...target, "--summary", summary, ...(verbose ? ["--verbose"] : [])], - new MockApi({ "GET /repos/o/r": { data: { has_wiki: false, private: false } } }), - ); - expect(result.code).toBe(1); - expect(result.stdout + result.stderr).not.toContain(TOKEN); - const message = `Error: ENOENT: no such file or directory, open '${masked}'`; - if (!verbose) { - expect(result.stderr).toBe( - `error: github-settings-as-code stopped unexpectedly: ${message}. Re-run with --verbose for the stack; if it recurs, file a bug with that output attached\n`, + (verbose) => + withTempDir("gsac-cli-", async (dir) => { + // The summary file's directory does not exist, so the write throws inside + // the run; the path carries the token, and the report must not. Without + // --verbose the remedy asks for it; with it the stack is the report. + const summary = join(dir, "missing", `${TOKEN}.md`); + const masked = summary.replaceAll(TOKEN, "***"); + const result = await cli( + ["check", ...target, "--summary", summary, ...(verbose ? ["--verbose"] : [])], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false, private: false } } }), ); - return; - } - // The stack's frames carry this machine's paths, so the line is pinned around them. - expect(result.stderr).toStartWith( - `error: github-settings-as-code stopped unexpectedly: ${message}\n at `, - ); - expect(result.stderr).toEndWith( - ". The stack above is the report: if it recurs, file a bug with it attached\n", - ); - expect(result.stderr).not.toContain("Re-run with --verbose"); - }, + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).not.toContain(TOKEN); + const message = `Error: ENOENT: no such file or directory, open '${masked}'`; + if (!verbose) { + expect(result.stderr).toBe( + `error: github-settings-as-code stopped unexpectedly: ${message}. Re-run with --verbose for the stack; if it recurs, file a bug with that output attached\n`, + ); + return; + } + // The stack's frames carry this machine's paths, so the line is pinned around them. + expect(result.stderr).toStartWith( + `error: github-settings-as-code stopped unexpectedly: ${message}\n at `, + ); + expect(result.stderr).toEndWith( + ". The stack above is the report: if it recurs, file a bug with it attached\n", + ); + expect(result.stderr).not.toContain("Re-run with --verbose"); + }), ); - test("--summary appends the run's markdown to the named file", async () => { - const summary = join(tempDir(), "summary.md"); - const result = await cli( - ["check", ...target, "--summary", summary], - new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), - ); - expect(result.code).toBe(0); - expect(readFileSync(summary, "utf8")).toContain("clean"); - }); + test("--summary appends the run's markdown to the named file", () => + withTempDir("gsac-cli-", async (dir) => { + const summary = join(dir, "summary.md"); + const result = await cli( + ["check", ...target, "--summary", summary], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), + ); + expect(result.code).toBe(0); + expect(readFileSync(summary, "utf8")).toContain("clean"); + })); }); describe("merge", () => { - test("folds the layers into the merged file, exits 0, and reports merged", async () => { - const out = join(tempDir(), "merged.yml"); - const result = await cli([ - "merge", - "--settings-file", - join(LAYERS, "fleet.yml"), - "--settings-file", - join(LAYERS, "team.yml"), - "--merged-file", - out, - ]); - expect(result.code).toBe(0); - expect(result.stdout).toEndWith( - "result: merged\nresult=merged\nskipped-sections=\nrepos-result={}\n", - ); - // The whole document only the fold produces: team.yml's label and rule join - // fleet.yml's under explicit policy wrappers, its `has_projects: null` removes - // fleet.yml's key, and pages passes through untouched. - expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ - repository: { has_wiki: false }, - labels: { - _undeclared: "delete", - entries: [ - { name: "bug", color: "d73a4a" }, - { name: "docs", color: "0075ca" }, - { name: "team", color: "00ff00" }, - ], - }, - rulesets: { - _undeclared: "keep", - entries: [ - { - name: "main", - target: "branch", - enforcement: "active", - rules: [{ type: "deletion" }, { type: "non_fast_forward" }], - }, - ], - }, - pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, - }); - }); + test("folds the layers into the merged file, exits 0, and reports merged", () => + withTempDir("gsac-cli-", async (dir) => { + const out = join(dir, "merged.yml"); + const result = await cli([ + "merge", + "--settings-file", + join(LAYERS, "fleet.yml"), + "--settings-file", + join(LAYERS, "team.yml"), + "--merged-file", + out, + ]); + expect(result.code).toBe(0); + expect(result.stdout).toEndWith( + "result: merged\nresult=merged\nskipped-sections=\nrepos-result={}\n", + ); + // The whole document only the fold produces: team.yml's label and rule join + // fleet.yml's under explicit policy wrappers, its `has_projects: null` removes + // fleet.yml's key, and pages passes through untouched. + expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ + repository: { has_wiki: false }, + labels: { + _undeclared: "delete", + entries: [ + { name: "bug", color: "d73a4a" }, + { name: "docs", color: "0075ca" }, + { name: "team", color: "00ff00" }, + ], + }, + rulesets: { + _undeclared: "keep", + entries: [ + { + name: "main", + target: "branch", + enforcement: "active", + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }, + ], + }, + pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, + }); + })); test("a merge without merged-file fails with the action's line", async () => { const result = await cli(["merge", "--settings-file", join(LAYERS, "fleet.yml")]); @@ -308,36 +298,37 @@ describe("merge", () => { describe("snapshot", () => { const LABEL = { name: "bug", color: "d73a4a", description: "Something is broken" }; - test("writes the live settings to the snapshot file, exits 0, and reports snapshot", async () => { - const out = join(tempDir(), "out", "snapshot.yml"); - const api = new MockApi({ - "GET /repos/o/r": { data: { private: false } }, - "GET /repos/o/r/labels?per_page=100&page=1": { data: [LABEL] }, - }); - const result = await cli( - [ - "snapshot", - "--token", - TOKEN, - "--repository", - "o/r", - "--snapshot-file", - out, - "--sections", - "labels", - ], - api, - ); - expect(result).toEqual({ - code: 0, - stdout: `snapshot written to ${out}\nresult: snapshot\nresult=snapshot\nskipped-sections=\nrepos-result={}\n`, - stderr: "", - }); - expect(api.mutations()).toEqual([]); - expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ - labels: { _undeclared: "delete", entries: [LABEL] }, - }); - }); + test("writes the live settings to the snapshot file, exits 0, and reports snapshot", () => + withTempDir("gsac-cli-", async (dir) => { + const out = join(dir, "out", "snapshot.yml"); + const api = new MockApi({ + "GET /repos/o/r": { data: { private: false } }, + "GET /repos/o/r/labels?per_page=100&page=1": { data: [LABEL] }, + }); + const result = await cli( + [ + "snapshot", + "--token", + TOKEN, + "--repository", + "o/r", + "--snapshot-file", + out, + "--sections", + "labels", + ], + api, + ); + expect(result).toEqual({ + code: 0, + stdout: `snapshot written to ${out}\nresult: snapshot\nresult=snapshot\nskipped-sections=\nrepos-result={}\n`, + stderr: "", + }); + expect(api.mutations()).toEqual([]); + expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ + labels: { _undeclared: "delete", entries: [LABEL] }, + }); + })); test("a snapshot without a destination fails before any API call, the action's line on stderr", async () => { const api = new MockApi({}); @@ -370,51 +361,54 @@ describe("validate and permissions", () => { }); }); - test("validate: an invalid file exits 1 with the validator's line", async () => { - const file = join(tempDir(), "bad.yml"); - writeFileSync(file, "labels:\n - color: d73a4a\n"); - const result = await cli(["validate", file]); - expect(result.code).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toStartWith(`error: ${file} has malformed section entries:`); - // The same message, once on stderr and once as the object's problem. - const problem = result.stderr.slice("error: ".length, -1); - const json = await cli(["validate", file, "--json"]); - expect(json.code).toBe(1); - expect(json.stderr).toBe(result.stderr); - expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); - }); - - test("validate: an unreadable file exits 1 naming the path", async () => { - const result = await cli(["validate", join(tempDir(), "missing.yml")]); - expect(result.code).toBe(1); - expect(result.stderr).toStartWith("error: cannot read settings from "); - }); - - test("permissions prints one grant per declared section, from the section declarations", async () => { - const file = join(tempDir(), "settings.yml"); - writeFileSync( - file, - "labels:\n - name: bug\n color: d73a4a\nrepository:\n has_wiki: false\n", - ); - const result = await cli(["permissions", file]); - expect(result).toEqual({ - code: 0, - stdout: `repository: ${sectionGrant(sectionModule("repository"))}\nlabels: ${sectionGrant(sectionModule("labels"))}\n`, - stderr: "", - }); - const json = await cli(["permissions", file, "--json"]); - expect(json.code).toBe(0); - expect(json.stderr).toBe(""); - expect(JSON.parse(json.stdout)).toEqual({ - result: "valid", - file, - grant: { - repository: sectionGrant(sectionModule("repository")), - labels: sectionGrant(sectionModule("labels")), - }, - }); - }); + test("validate: an invalid file exits 1 with the validator's line", () => + withTempDir("gsac-cli-", async (dir) => { + const file = join(dir, "bad.yml"); + writeFileSync(file, "labels:\n - color: d73a4a\n"); + const result = await cli(["validate", file]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toStartWith(`error: ${file} has malformed section entries:`); + // The same message, once on stderr and once as the object's problem. + const problem = result.stderr.slice("error: ".length, -1); + const json = await cli(["validate", file, "--json"]); + expect(json.code).toBe(1); + expect(json.stderr).toBe(result.stderr); + expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); + })); + + test("validate: an unreadable file exits 1 naming the path", () => + withTempDir("gsac-cli-", async (dir) => { + const result = await cli(["validate", join(dir, "missing.yml")]); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith("error: cannot read settings from "); + })); + + test("permissions prints one grant per declared section, from the section declarations", () => + withTempDir("gsac-cli-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync( + file, + "labels:\n - name: bug\n color: d73a4a\nrepository:\n has_wiki: false\n", + ); + const result = await cli(["permissions", file]); + expect(result).toEqual({ + code: 0, + stdout: `repository: ${sectionGrant(sectionModule("repository"))}\nlabels: ${sectionGrant(sectionModule("labels"))}\n`, + stderr: "", + }); + const json = await cli(["permissions", file, "--json"]); + expect(json.code).toBe(0); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout)).toEqual({ + result: "valid", + file, + grant: { + repository: sectionGrant(sectionModule("repository")), + labels: sectionGrant(sectionModule("labels")), + }, + }); + })); }); describe("the --json failure envelope", () => { diff --git a/test/docs/biome-schema.test.ts b/test/docs/biome-schema.test.ts index 9598283a..25a7a911 100644 --- a/test/docs/biome-schema.test.ts +++ b/test/docs/biome-schema.test.ts @@ -6,8 +6,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** The version segment of a biomejs.dev schema URL, or null when the URL has another shape. */ function biomeSchemaVersion(schemaUrl: string): string | null { diff --git a/test/docs/checks-sections-gate.test.ts b/test/docs/checks-sections-gate.test.ts index aa6d488d..acee0dbd 100644 --- a/test/docs/checks-sections-gate.test.ts +++ b/test/docs/checks-sections-gate.test.ts @@ -7,8 +7,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { ALL, NONE } from "../../.github/scripts/changed-sections.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; describe("checks.yml e2e-smoke section-selection sentinels", () => { const workflow = readFileSync(join(ROOT, ".github", "workflows", "checks.yml"), "utf8"); diff --git a/test/docs/checks-workflow.test.ts b/test/docs/checks-workflow.test.ts index 3010379e..fb8c333f 100644 --- a/test/docs/checks-workflow.test.ts +++ b/test/docs/checks-workflow.test.ts @@ -8,12 +8,12 @@ import { transitiveDependents, } from "../../.github/scripts/changed-sections.js"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; +import { ROOT } from "../root.js"; import { headRefPrefixes, headRefPrefixesIn } from "./head-ref.js"; import { type CompositeAction, executedLines, installs, - ROOT, readAction, readWorkflow, SETUP_USES, diff --git a/test/docs/ci-workflow.test.ts b/test/docs/ci-workflow.test.ts index 6a363efa..4710ba22 100644 --- a/test/docs/ci-workflow.test.ts +++ b/test/docs/ci-workflow.test.ts @@ -10,10 +10,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; +import { ROOT } from "../root.js"; import { headRefPrefixes } from "./head-ref.js"; -const ROOT = join(import.meta.dir, "..", ".."); - // Outside the gate on purpose: a red run flags template-convention drift the next sync PR heals; mirrors the central CI's validator // (docs/all-green.md in the platform repository). const INFORMATIONAL = new Set(["validate-template"]); diff --git a/test/docs/coverage.test.ts b/test/docs/coverage.test.ts index acf1bb61..a079be42 100644 --- a/test/docs/coverage.test.ts +++ b/test/docs/coverage.test.ts @@ -14,8 +14,8 @@ import { } from "../../src/sections/contract/endpoints.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { allEndpoints, allGraphqlOps, SECTIONS } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const coverage = renderCoverage(SECTIONS, DOCS, COVERAGE_DATA); describe("COVERAGE path citations", () => { diff --git a/test/docs/diagrams.test.ts b/test/docs/diagrams.test.ts index 6bd60fa8..393c58a8 100644 --- a/test/docs/diagrams.test.ts +++ b/test/docs/diagrams.test.ts @@ -10,9 +10,9 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { ROOT } from "../root.js"; import { fencedBlocks } from "./markdown.js"; -const ROOT = join(import.meta.dir, "..", ".."); const DOCS = join(ROOT, "docs"); /** The URL prefix docs/ pages use to reach a repository file (relative links may not leave docs/). */ diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 46450e09..bf44a846 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -14,12 +14,12 @@ import { SECTION_KEYS } from "../../src/schema.js"; import { NESTED_KEYS } from "../../src/sections/environments/nested.js"; import { SECTIONS } from "../../src/sections/registry.js"; import { STALE_VERSION_HINT } from "../../src/sections/secret_scanning_custom_patterns/index.js"; +import { ROOT } from "../root.js"; import { deleteEnumerationProblems } from "./claims.js"; import { fencedBlocks, sectionLines } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; import { stalePins } from "./version-pins.js"; -const ROOT = join(import.meta.dir, "..", ".."); const DOCS = join(ROOT, "docs"); const REQUIRED_PAGES = [ diff --git a/test/docs/issue-templates.test.ts b/test/docs/issue-templates.test.ts index 91c000f3..60bdaaee 100644 --- a/test/docs/issue-templates.test.ts +++ b/test/docs/issue-templates.test.ts @@ -4,8 +4,8 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const TEMPLATES = join(ROOT, ".github", "ISSUE_TEMPLATE"); interface LabelEntry { diff --git a/test/docs/library-examples.test.ts b/test/docs/library-examples.test.ts index 593f1fb5..d8ade95b 100644 --- a/test/docs/library-examples.test.ts +++ b/test/docs/library-examples.test.ts @@ -11,8 +11,8 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const PAGE = "docs/reference/library.md"; const PACKAGE = "@vivswan/github-settings-as-code"; diff --git a/test/docs/permissions.test.ts b/test/docs/permissions.test.ts index 135ef684..7cf3fc71 100644 --- a/test/docs/permissions.test.ts +++ b/test/docs/permissions.test.ts @@ -7,8 +7,8 @@ import { overrideAdviceLevel } from "../../src/sections/contract/errors.js"; import { RESOURCE_LABEL, type SectionPermission } from "../../src/sections/contract/permissions.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { allEndpoints, sectionModule } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); // Flattened for matching: the pages wrap sentences across lines. const permissions = readFileSync(join(ROOT, "docs", "reference", "permissions.md"), "utf8").replace( /\s+/g, diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 9c69d9b1..e5772a46 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -12,12 +12,12 @@ import { PRIVATE_REPORT_CHANNELS } from "../../src/report/delivery.js"; import { PROBOT_PARITY_KEYS, SECTION_KEYS } from "../../src/schema.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { SECTIONS } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; import { defaultClaimProblems, deleteEnumerationProblems } from "./claims.js"; import { fencedBlocks, sectionLines } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; import { stalePins } from "./version-pins.js"; -const ROOT = join(import.meta.dir, "..", ".."); const readme = readFileSync(join(ROOT, "README.md"), "utf8"); function assertBacktickedEnumeration( diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 267ba8bd..a41e4b4f 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -10,9 +10,9 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isAlias, isMap, isScalar, parseDocument, visit } from "yaml"; +import { ROOT } from "../root.js"; import { type Job, - ROOT, readAction, readWorkflow, repoOwnedWorkflowFiles, diff --git a/test/docs/semantics.test.ts b/test/docs/semantics.test.ts index a800de95..689b8cea 100644 --- a/test/docs/semantics.test.ts +++ b/test/docs/semantics.test.ts @@ -9,8 +9,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { countWord } from "../../.github/scripts/lib/count-word.js"; import { MAX_RETRIES, MAX_RETRY_WAIT_S } from "../../src/github/api.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const semantics = readFileSync(join(ROOT, "docs", "reference", "semantics.md"), "utf8").replace( /\s+/g, " ", diff --git a/test/docs/version-pins.ts b/test/docs/version-pins.ts index 3618853c..73e34422 100644 --- a/test/docs/version-pins.ts +++ b/test/docs/version-pins.ts @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** One stale workflow-snippet pin: where it sits and what it names. */ export interface StalePin { diff --git a/test/docs/workflow-loader.ts b/test/docs/workflow-loader.ts index 1d299903..9f64d311 100644 --- a/test/docs/workflow-loader.ts +++ b/test/docs/workflow-loader.ts @@ -6,8 +6,8 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { ROOT } from "../root.js"; -export const ROOT = join(import.meta.dir, "..", ".."); const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); /** The setup composite (bun, the locked install, yamllint on request), as a job's `uses:` spells it. */ export const SETUP_USES = "./.github/actions/setup"; diff --git a/test/e2e/foundation.test.ts b/test/e2e/foundation.test.ts index 345d6c75..ec75b502 100644 --- a/test/e2e/foundation.test.ts +++ b/test/e2e/foundation.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { MARKER_LABEL, MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import { SECTION_KEYS } from "../../src/schema.js"; +import { ROOT } from "../root.js"; import { ADMIN_OWNER, ADMIN_REPO, @@ -482,12 +483,11 @@ describe("harness identity constants", () => { test("section mock fragments mint identity from state.slug, never the harness constants", async () => { // Urls minted from ADMIN_SLUG were served verbatim for multi-repo targets in five fragments, so the // class is banned at the import boundary: a fragment always has the owning state in scope. - const root = join(import.meta.dir, "..", ".."); const offenders: string[] = []; let fragments = 0; - for await (const file of new Bun.Glob("src/sections/*/mock.ts").scan(root)) { + for await (const file of new Bun.Glob("src/sections/*/mock.ts").scan(ROOT)) { fragments++; - const text = await Bun.file(join(root, file)).text(); + const text = await Bun.file(join(ROOT, file)).text(); if (/from "[^"]*\/test\/e2e\/constants\.js"/.test(text)) { offenders.push(file); } diff --git a/test/e2e/openapi/validate.test.ts b/test/e2e/openapi/validate.test.ts index 19aa6f3f..f5e56400 100644 --- a/test/e2e/openapi/validate.test.ts +++ b/test/e2e/openapi/validate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { endpointMethod, endpointPath } from "../../../src/sections/contract/endpoints.js"; import { allEndpoints } from "../../../src/sections/registry.js"; import type { LoggedRequest } from "../mock/contract.js"; @@ -116,7 +117,7 @@ describe("toJsonSchema", () => { test("leaves primitives untouched", () => { expect(toJsonSchema("s")).toBe("s"); expect(toJsonSchema(3)).toBe(3); - expect(toJsonSchema(null)).toBe(null); + expect(toJsonSchema(null)).toBeNull(); }); test("relaxed variant rewrites oneOf to anyOf (widened branches may overlap)", () => { @@ -308,7 +309,7 @@ describe("OpenApiValidator against the fetched spec", () => { // oversized per_page and the page loop stops on a short page, so an undeclared sub-100 cap // silently truncates after page one (the variables family is capped at 30). const spec = JSON.parse( - readFileSync(new URL("./github-openapi.trimmed.json", import.meta.url), "utf8"), + readFileSync(join(import.meta.dir, "github-openapi.trimmed.json"), "utf8"), ) as { paths?: Record< string, diff --git a/test/e2e/runner.ts b/test/e2e/runner.ts index b457688b..c099bafd 100644 --- a/test/e2e/runner.ts +++ b/test/e2e/runner.ts @@ -23,6 +23,7 @@ import { dirname, join, relative } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { parseRepoSlug } from "../../src/discovery/targets.js"; import type { OutputName } from "../../src/io.js"; +import { ROOT } from "../root.js"; import { assertApplyIdempotent, captureRerun, @@ -37,7 +38,6 @@ import { type ServerOptions, startMockServer } from "./mock/server.js"; import { sharedValidator } from "./openapi/validate.js"; import { collectYmlFiles, type Expect, type Scenario, settingsYamlFor } from "./schema.js"; -const ROOT = join(import.meta.dir, "..", ".."); /** * The production bundle command, pinned verbatim so the Bun.build call below cannot drift from it: a * flag added to build:bundle (minify, sourcemap, define) would make e2e exercise a different artifact diff --git a/test/e2e/schema.ts b/test/e2e/schema.ts index db112e88..8612d595 100644 --- a/test/e2e/schema.ts +++ b/test/e2e/schema.ts @@ -14,6 +14,7 @@ import { SECTION_KEYS } from "../../src/schema.js"; import type { PatResource } from "../../src/sections/contract/permissions.js"; import { renamedKeyError } from "../../src/sections/shared/renamed-key.js"; import type { MustBeNever } from "../../src/types.js"; +import { ROOT } from "../root.js"; import { LAYER_FILE_PREFIX, RUNNER_ROOT_FILES } from "./constants.js"; import type { LiveState } from "./mock/state.js"; import { LIVE_STATE_KEYS } from "./mock/state.js"; @@ -601,9 +602,7 @@ export function collectYmlFiles(dir: string): string[] { * test/e2e/scenarios/ -> multi-section flows; a scenario exercising ONE section lives with that section * //scenarios/ -> every registered section, so a new section's first scenario needs no list edit */ -export function scenarioRoots( - sectionsDir: string = join(import.meta.dir, "..", "..", "src", "sections"), -): string[] { +export function scenarioRoots(sectionsDir: string = join(ROOT, "src", "sections")): string[] { return [ join(import.meta.dir, "scenarios"), ...SECTION_KEYS.map((key) => join(sectionsDir, key, "scenarios")), diff --git a/test/engine/orchestrate.test.ts b/test/engine/orchestrate.test.ts index 9ca99ad1..f6134d7b 100644 --- a/test/engine/orchestrate.test.ts +++ b/test/engine/orchestrate.test.ts @@ -10,8 +10,7 @@ import { validateSettingsDoc, } from "../../src/engine/orchestrate.js"; import { SectionSelection } from "../../src/engine/section-selection.js"; -import type { Io } from "../../src/io.js"; -import { maskRegistry, prefixedIo } from "../../src/io.js"; +import { prefixedIo, silentIo } from "../../src/io.js"; import { describeProblem, type TopLevelShape } from "../../src/problem.js"; import { SECTION_KEYS, type SettingsFile } from "../../src/schema.js"; import type { SectionModule } from "../../src/sections/contract/module.js"; @@ -20,38 +19,12 @@ import { interactionLimitsSection } from "../../src/sections/interaction_limits/ import { pagesSection } from "../../src/sections/pages/index.js"; import { rulesetsSection } from "../../src/sections/rulesets/index.js"; import { workflowsSection } from "../../src/sections/workflows/index.js"; +import { captureIo } from "../io/capture.js"; import { MockApi } from "../mock-api.js"; -function captureIo(): { io: Io; annotations: string[]; logs: string[]; masked: string[] } { - const annotations: string[] = []; - const logs: string[] = []; - const masked: string[] = []; - return { - io: { - annotate: (level, message) => annotations.push(`${level}: ${message}`), - log: (line) => logs.push(line), - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry((value) => masked.push(value)), - }, - annotations, - logs, - masked, - }; -} - /** Brand fixtures through the REAL boundary: an invalid fixture fails here instead of riding a cast into runForRepo. */ function validated(doc: SettingsFile): ValidatedSettings { - const silent: Io = { - annotate: () => {}, - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }; - return validateSettingsDoc(doc, "test fixture", new Set(), silent).match( + return validateSettingsDoc(doc, "test fixture", new Set(), silentIo()).match( (settings) => settings, (problem) => { throw new Error(`test fixture failed validation: ${describeProblem(problem)}`); @@ -259,25 +232,18 @@ describe("runForRepo secret references", () => { test("apply resolves up front, masks before the first mutation, and hands handlers plaintext", async () => { const api = new MockApi({ [HOOKS_LIST]: { data: [] } }).allowMutations("POST /repos/o/r/hooks"); - const { io, masked } = captureIo(); const mutationsAtMaskTime: number[] = []; - const trackingIo: Io = { - ...io, - ...maskRegistry((value) => { - mutationsAtMaskTime.push(api.mutations().length); - io.mask(value); - }), - }; + const { io, masks } = captureIo(() => mutationsAtMaskTime.push(api.mutations().length)); const result = await runForRepo( api, opts({ settings: webhookSettings("$WEBHOOK_SECRET"), secretEnv: { WEBHOOK_SECRET: "s3cret-plaintext" }, }), - trackingIo, + io, ); expect(result.result).toBe("applied"); - expect(masked).toEqual(["s3cret-plaintext"]); + expect(masks).toEqual(["s3cret-plaintext"]); expect(mutationsAtMaskTime).toEqual([0]); const post = api.mutations()[0]?.payload as { config?: { secret?: string } }; expect(post?.config?.secret).toBe("s3cret-plaintext"); diff --git a/test/engine/secrets.test.ts b/test/engine/secrets.test.ts index 41545d55..4d6ef231 100644 --- a/test/engine/secrets.test.ts +++ b/test/engine/secrets.test.ts @@ -7,10 +7,11 @@ import { import { type SettingsSource, validateSecretRef } from "../../src/engine/secret-refs.js"; import { collectSecretValues, snapshotSecretReference } from "../../src/engine/secrets.js"; import { SectionSelection } from "../../src/engine/section-selection.js"; -import { type Io, maskRegistry } from "../../src/io.js"; +import { silentIo } from "../../src/io.js"; import { describeProblem } from "../../src/problem.js"; import type { SectionKey, SettingsFile } from "../../src/schema.js"; import { SECTIONS } from "../../src/sections/registry.js"; +import { captureIo } from "../io/capture.js"; import { MockApi } from "../mock-api.js"; /** The operator defaults under test: one fleet secret, applied whole to fileless targets. */ @@ -23,21 +24,6 @@ function valuesOf(doc: SettingsFile, source: SettingsSource) { return collectSecretValues(doc, SECTIONS, source); } -function captureIo(): { io: Io; annotations: string[] } { - const annotations: string[] = []; - return { - io: { - annotate: (level, message) => annotations.push(`${level}: ${message}`), - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }, - annotations, - }; -} - /** The label collectSecretValues derives for the fleet secret entry. */ const FLEET_LABEL = 'the secret entry "FLEET_TOKEN"'; /** The label webhooks derives for the test hook's config.secret. */ @@ -143,15 +129,7 @@ describe("secret provenance is one source per document", () => { describe("runForRepo provenance", () => { // Branded through the REAL boundary, so an invalid fixture fails here instead of riding a cast. const validated = (doc: unknown): ValidatedSettings => { - const silent: Io = { - annotate: () => {}, - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }; - const verdict = validateSettingsDoc(doc, "fixture", new Set(), silent); + const verdict = validateSettingsDoc(doc, "fixture", new Set(), silentIo()); if (verdict.isErr()) { throw new Error(`fixture failed validation: ${describeProblem(verdict.error)}`); } diff --git a/test/engine/snapshot.test.ts b/test/engine/snapshot.test.ts index b121e3b3..9c1aebaa 100644 --- a/test/engine/snapshot.test.ts +++ b/test/engine/snapshot.test.ts @@ -13,8 +13,6 @@ import { snapshotRepository, } from "../../src/engine/snapshot.js"; import type { GithubClient } from "../../src/github/api.js"; -import type { Io } from "../../src/io.js"; -import { maskRegistry } from "../../src/io.js"; import { endpointPermission, type SectionMeta, @@ -25,25 +23,11 @@ import { pagesSection } from "../../src/sections/pages/index.js"; import { allGraphqlOps, SECTIONS } from "../../src/sections/registry.js"; import { matchEndpoint } from "../e2e/mock/dispatch.js"; import type { LiveState } from "../e2e/mock/state.js"; +import { captureIo } from "../io/capture.js"; import { registryFake } from "../sections/fragment-fake.js"; import { REPO } from "../sections/section-run.js"; import type { Row } from "../sections/snapshot-roundtrip.js"; -function captureIo(): { io: Io; annotations: string[] } { - const annotations: string[] = []; - return { - io: { - annotate: (level, message) => annotations.push(`${level}: ${message}`), - log: () => {}, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry(() => {}), - }, - annotations, - }; -} - /** A client that answers `status` (the fine-grained denial is 404) to GETs whose path matches `denied`. */ function denying(api: GithubClient, denied: RegExp, status: 403 | 404 = 404): GithubClient { return { diff --git a/test/flows/deliver.test.ts b/test/flows/deliver.test.ts index 922ecc19..d1224065 100644 --- a/test/flows/deliver.test.ts +++ b/test/flows/deliver.test.ts @@ -19,37 +19,17 @@ import { redactedChannel, type TargetChannel, } from "../../src/flows/redact.js"; -import { type Io, maskRegistry } from "../../src/io.js"; +import type { Io } from "../../src/io.js"; import { isPrivate } from "../../src/private.js"; import type { ArtifactUploader } from "../../src/report/artifact-report.js"; import { REPORT_HEADING } from "../../src/report/composer.js"; import type { PrivateReportChannel } from "../../src/report/delivery.js"; +import { captureIo } from "../io/capture.js"; import { MockApi } from "../mock-api.js"; const MARKER = "settings-as-code-report"; const ISSUE_TITLE = "[automated] settings-as-code: private settings report"; -/** Every channel of the port in one ordered event log, so a test pins the sequence, not one surface. */ -function eventIo(): { io: Io; events: string[]; outputs: Record } { - const events: string[] = []; - const outputs: Record = {}; - return { - io: { - annotate: (level, message) => events.push(`${level}: ${message}`), - log: (line) => events.push(`log: ${line}`), - debug: () => {}, - summary: (markdown) => events.push(`summary: ${markdown.split("\n")[0]}`), - output: (name, value) => { - outputs[name] = value; - events.push(`output ${name}=${value}`); - }, - ...maskRegistry(() => {}), - }, - events, - outputs, - }; -} - /** A MockApi whose requests land in the same event log as the port's lines. */ class TracingApi extends MockApi { constructor( @@ -115,7 +95,7 @@ async function delivered( body: (delivery: Delivery, io: Io, events: string[]) => Promise, uploader?: ArtifactUploader, ) { - const { io, events } = eventIo(); + const { io, events } = captureIo(); const api = new TracingApi(routes, events); await withDelivery({ api, cfg: config, io, uploader }, (delivery) => body(delivery, io, events)); return { api, events }; @@ -148,7 +128,7 @@ describe("runOutcome", () => { describe("engineOutcome", () => { test("passes a run through, and turns a preflight denial into one channel line and a note", () => { - const { io, events } = eventIo(); + const { io, events } = captureIo(); const ran = { repo: "o/r", result: "applied" as const, outcomes: [], preflightDenied: [] }; expect(engineOutcome(ran, io)).toEqual({ result: "applied", outcomes: [] }); const denied = { ...ran, result: "failed" as const, preflightDenied: ["labels", "rulesets"] }; @@ -158,7 +138,7 @@ describe("engineOutcome", () => { note: "preflight denied 2 section(s); nothing was applied to this repository", }); expect(events).toEqual([ - "error: preflight failed: the token cannot access 2 section(s), so nothing was applied to this repository. Grant the permissions named above, or set on-missing-permission: warn to skip those sections", + "annotate error: preflight failed: the token cannot access 2 section(s), so nothing was applied to this repository. Grant the permissions named above, or set on-missing-permission: warn to skip those sections", ]); }); }); @@ -205,7 +185,7 @@ describe("withDelivery", () => { expect(redacted.display).toBe("private repository #1"); expect(isPrivate(redacted.detail)).toBe(true); expect(events).toEqual([ - `error: private repository #1: failed - labels (403). ${REDACTED_NOTE}`, + `annotate error: private repository #1: failed - labels (403). ${REDACTED_NOTE}`, ]); events.length = 0; @@ -221,7 +201,7 @@ describe("withDelivery", () => { detail: { slug: "o/pub", outcomes: FAILED_LABELS, note: undefined }, }); // The engine's own line already carried the slug; delivery adds nothing. - expect(events).toEqual(["error: o/pub: labels: 403 Forbidden"]); + expect(events).toEqual(["annotate error: o/pub: labels: 403 Forbidden"]); }); expect(api.calls).toEqual([]); }); @@ -240,7 +220,7 @@ describe("withDelivery", () => { "api POST /repos/o/priv/labels", `api GET /repos/o/priv/issues?state=all&labels=${MARKER}&per_page=100&page=1`, "api PATCH /repos/o/priv/issues/7", - `warning: private repository #1: drift - labels. ${REDACTED_NOTE}`, + `annotate warning: private repository #1: drift - labels. ${REDACTED_NOTE}`, ]); events.length = 0; @@ -255,7 +235,7 @@ describe("withDelivery", () => { ); expect(events).toHaveLength(1); expect(events[0]).toStartWith( - "notice: private repository #2: visibility could not be verified", + "annotate notice: private repository #2: visibility could not be verified", ); expect(events.join("\n")).not.toContain("o/maybe"); }, @@ -300,8 +280,8 @@ describe("withDelivery", () => { uploader, ); expect(events).toEqual([ - `warning: private repository #1: drift - labels. ${REDACTED_NOTE}`, - `warning: private repository #2: drift - labels. ${REDACTED_NOTE}`, + `annotate warning: private repository #1: drift - labels. ${REDACTED_NOTE}`, + `annotate warning: private repository #2: drift - labels. ${REDACTED_NOTE}`, "upload settings-as-code-private-report", ]); expect(uploads).toHaveLength(1); @@ -347,7 +327,7 @@ describe("concludeRun", () => { }; test("single: the summary, the outputs, the result line, and the exit code, in that order", () => { - const { io, events, outputs } = eventIo(); + const { io, events, outputs } = captureIo(); const channel: TargetChannel = publicChannel(io, "o/r", false); const code = concludeRun(io, { kind: "single", @@ -376,7 +356,7 @@ describe("concludeRun", () => { }); test("multi: repos-result keys a redacted target by its placeholder, skipped sections dedupe across targets, worst-of decides", () => { - const { io, events, outputs } = eventIo(); + const { io, events, outputs } = captureIo(); const hidden = redactedChannel(io, "o/priv", "private repository #1"); const shown = publicChannel(io, "o/pub", true); const code = concludeRun(io, { @@ -422,11 +402,11 @@ describe("concludeRun", () => { }); test("failRun: the problem's line, then the conclusion a failed target gets, with no summary", () => { - const { io, events, outputs } = eventIo(); + const { io, events, outputs } = captureIo(); expect(failRun(io, { code: "input-token-missing" })).toBe(1); expect(outputs).toEqual({ result: "failed", "skipped-sections": "", "repos-result": "{}" }); expect(events).toEqual([ - 'error: cannot call the GitHub API: no token was provided. Set the "token" input on the action step (or export GITHUB_TOKEN)', + 'annotate error: cannot call the GitHub API: no token was provided. Set the "token" input on the action step (or export GITHUB_TOKEN)', "output result=failed", "output skipped-sections=", "output repos-result={}", diff --git a/test/flows/execute.test.ts b/test/flows/execute.test.ts index d1f87105..2c0c30f4 100644 --- a/test/flows/execute.test.ts +++ b/test/flows/execute.test.ts @@ -3,9 +3,7 @@ * conclusions. The two faces running the same arms to the same result is pinned in test/cli/execution.test.ts. */ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { generateX25519Identity, identityToRecipient } from "age-encryption"; import { @@ -19,8 +17,9 @@ import { SectionSelection, } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; -const ROOT = join(import.meta.dir, "..", ".."); const LAYERS = join(ROOT, "test", "fixtures", "layers"); type SingleConfig = Extract; @@ -42,19 +41,6 @@ const single = (overrides: Partial = {}): SingleConfig => ({ ...overrides, }); -const scratch: string[] = []; -afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function tempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "gsac-execute-")); - scratch.push(dir); - return dir; -} - /** Deps over a collecting Io and one stub client; `createClient` records whether the run asked for it. */ function deps(api: MockApi, overrides: Partial = {}) { const collected = collectingIo(); @@ -71,50 +57,52 @@ function deps(api: MockApi, overrides: Partial = {}) { } describe("executeRun", () => { - test("a merge opens no client and ends at the merge conclusion", async () => { - const mergedFile = join(tempDir(), "merged.yml"); - const api = new MockApi({}); - const d = deps(api); - const code = await executeRun( - { - kind: "merge", - settingsFiles: [join(LAYERS, "fleet.yml"), join(LAYERS, "team.yml")], - mergedFile, - layering: "merge", - }, - d.run, - ); - expect(code).toBe(0); - expect(d.opened()).toBe(0); - expect(api.calls).toEqual([]); - expect(d.collected.outputs).toEqual({ - result: "merged", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(d.collected.lines.slice(-2)).toEqual([ - { line: `merged 2 layer(s) into ${mergedFile}` }, - { line: "result: merged" }, - ]); - }); + test("a merge opens no client and ends at the merge conclusion", () => + withTempDir("gsac-execute-", async (dir) => { + const mergedFile = join(dir, "merged.yml"); + const api = new MockApi({}); + const d = deps(api); + const code = await executeRun( + { + kind: "merge", + settingsFiles: [join(LAYERS, "fleet.yml"), join(LAYERS, "team.yml")], + mergedFile, + layering: "merge", + }, + d.run, + ); + expect(code).toBe(0); + expect(d.opened()).toBe(0); + expect(api.calls).toEqual([]); + expect(d.collected.outputs).toEqual({ + result: "merged", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(d.collected.lines.slice(-2)).toEqual([ + { line: `merged 2 layer(s) into ${mergedFile}` }, + { line: "result: merged" }, + ]); + })); - test("a fatal problem raised after the parse is worded by the face's describe", async () => { - const settingsFile = join(tempDir(), "missing.yml"); - const api = new MockApi({}); - const d = deps(api, { describe: (problem) => `worded: ${problem.code}` }); - expect(await executeRun(single({ settingsFile }), d.run)).toBe(1); - expect(d.opened()).toBe(1); - expect(api.calls).toEqual([]); - expect(d.collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(d.collected.lines).toEqual([ - { level: "error", line: "worded: settings-file-unreadable" }, - { line: "result: failed" }, - ]); - }); + test("a fatal problem raised after the parse is worded by the face's describe", () => + withTempDir("gsac-execute-", async (dir) => { + const settingsFile = join(dir, "missing.yml"); + const api = new MockApi({}); + const d = deps(api, { describe: (problem) => `worded: ${problem.code}` }); + expect(await executeRun(single({ settingsFile }), d.run)).toBe(1); + expect(d.opened()).toBe(1); + expect(api.calls).toEqual([]); + expect(d.collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(d.collected.lines).toEqual([ + { level: "error", line: "worded: settings-file-unreadable" }, + { line: "result: failed" }, + ]); + })); test("the artifact channel is the uploader dep's: absent it fails before any API call, present the run reaches the API", async () => { const reportPublicKey = await identityToRecipient(await generateX25519Identity()); diff --git a/test/flows/layers.test.ts b/test/flows/layers.test.ts index 6e2f3973..5418f873 100644 --- a/test/flows/layers.test.ts +++ b/test/flows/layers.test.ts @@ -1,50 +1,50 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, spyOn, test } from "bun:test"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok } from "neverthrow"; import { readLayerFiles } from "../../src/flows/layers.js"; import * as settingsRead from "../../src/flows/settings-read.js"; +import { withTempDir } from "../temp-dir.js"; describe("readLayerFiles", () => { - let dir = ""; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "read-layers-")); + /** Two readable layers and one with broken YAML, written into `dir`. */ + function writeLayers(dir: string): void { writeFileSync(join(dir, "fleet.yml"), "repository:\n has_wiki: false\n"); writeFileSync(join(dir, "repo.yml"), "repository:\n has_issues: true\n"); writeFileSync(join(dir, "broken.yml"), "labels: [oops, unclosed\n"); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); + } - test("reads every layer in path order, each named by its path", () => { - const fleet = join(dir, "fleet.yml"); - const repo = join(dir, "repo.yml"); - expect(readLayerFiles([repo, fleet])).toEqual( - ok([ - { name: repo, doc: { repository: { has_issues: true } } }, - { name: fleet, doc: { repository: { has_wiki: false } } }, - ]), - ); - }); - - test("the first unreadable layer is the problem, and the layers after it are never read", () => { - const read = spyOn(settingsRead, "readSettingsFile"); - try { - const missing = join(dir, "missing.yml"); - expect(readLayerFiles([missing, join(dir, "broken.yml")])).toEqual( - err({ - code: "settings-file-unreadable" as const, - role: "layer" as const, - path: missing, - reason: expect.stringContaining("ENOENT"), - }), + test("reads every layer in path order, each named by its path", () => + withTempDir("read-layers-", (dir) => { + writeLayers(dir); + const fleet = join(dir, "fleet.yml"); + const repo = join(dir, "repo.yml"); + expect(readLayerFiles([repo, fleet])).toEqual( + ok([ + { name: repo, doc: { repository: { has_issues: true } } }, + { name: fleet, doc: { repository: { has_wiki: false } } }, + ]), ); - // The broken layer's own syntax error never becomes a candidate: one read, the failed one. - expect(read.mock.calls).toEqual([[missing, "layer"]]); - } finally { - read.mockRestore(); - } - }); + })); + + test("the first unreadable layer is the problem, and the layers after it are never read", () => + withTempDir("read-layers-", (dir) => { + writeLayers(dir); + const read = spyOn(settingsRead, "readSettingsFile"); + try { + const missing = join(dir, "missing.yml"); + expect(readLayerFiles([missing, join(dir, "broken.yml")])).toEqual( + err({ + code: "settings-file-unreadable" as const, + role: "layer" as const, + path: missing, + reason: expect.stringContaining("ENOENT"), + }), + ); + // The broken layer's own syntax error never becomes a candidate: one read, the failed one. + expect(read.mock.calls).toEqual([[missing, "layer"]]); + } finally { + read.mockRestore(); + } + })); }); diff --git a/test/flows/merge.test.ts b/test/flows/merge.test.ts index 9ab40867..c6a91b5f 100644 --- a/test/flows/merge.test.ts +++ b/test/flows/merge.test.ts @@ -1,65 +1,63 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok } from "neverthrow"; import { collectingIo, concludeMerge, type MergeConfig, runMerge } from "../../src/index.js"; +import { withTempDir } from "../temp-dir.js"; const FLEET = "repository:\n has_wiki: false\n"; const REPO = "repository:\n has_issues: true\n"; describe("runMerge", () => { - let dir = ""; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "run-merge-")); + /** The two layers written into `dir`, and the config folding them into `mergedFile`. */ + const cfg = (dir: string, mergedFile: string): MergeConfig => { writeFileSync(join(dir, "fleet.yml"), FLEET); writeFileSync(join(dir, "repo.yml"), REPO); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); + return { + settingsFiles: [join(dir, "fleet.yml"), join(dir, "repo.yml")], + mergedFile, + layering: "merge", + }; + }; - const cfg = (mergedFile: string): MergeConfig => ({ - settingsFiles: [join(dir, "fleet.yml"), join(dir, "repo.yml")], - mergedFile, - layering: "merge", - }); - - test("folds the layers into merged-file, and the finished merge concludes merged", () => { - const collected = collectingIo(); - const mergedFile = join(dir, "out", "merged.yml"); - const merged = runMerge(cfg(mergedFile), collected.io); - expect(merged).toEqual(ok({ layers: cfg(mergedFile).settingsFiles, mergedFile })); - expect(readFileSync(mergedFile, "utf8")).toBe( - "repository:\n has_wiki: false\n has_issues: true\n", - ); - expect(concludeMerge(collected.io, merged._unsafeUnwrap())).toBe(0); - expect(collected.outputs).toEqual({ - result: "merged", - "skipped-sections": "", - "repos-result": "{}", - }); - }); + test("folds the layers into merged-file, and the finished merge concludes merged", () => + withTempDir("run-merge-", (dir) => { + const collected = collectingIo(); + const mergedFile = join(dir, "out", "merged.yml"); + const config = cfg(dir, mergedFile); + const merged = runMerge(config, collected.io); + expect(merged).toEqual(ok({ layers: config.settingsFiles, mergedFile })); + expect(readFileSync(mergedFile, "utf8")).toBe( + "repository:\n has_wiki: false\n has_issues: true\n", + ); + expect(concludeMerge(collected.io, merged._unsafeUnwrap())).toBe(0); + expect(collected.outputs).toEqual({ + result: "merged", + "skipped-sections": "", + "repos-result": "{}", + }); + })); test.each<[string, (dir: string) => string, number, string]>([ ["a layer's own path", (d) => join(d, "repo.yml"), 1, "repo.yml"], ["a ./ spelling of a layer, compared resolved", (d) => `${d}/./fleet.yml`, 0, "fleet.yml"], ])( "a merged-file naming %s fails before any write, naming the layer's position", - (_case, mergedFile, index, layer) => { - const collected = collectingIo(); - const target = mergedFile(dir); - expect(runMerge(cfg(target), collected.io)).toEqual( - err({ - code: "merged-file-is-layer" as const, - mergedFile: target, - index, - layer: join(dir, layer), - }), - ); - expect(collected.lines).toEqual([]); - expect(readFileSync(join(dir, "fleet.yml"), "utf8")).toBe(FLEET); - expect(readFileSync(join(dir, "repo.yml"), "utf8")).toBe(REPO); - }, + (_case, mergedFile, index, layer) => + withTempDir("run-merge-", (dir) => { + const collected = collectingIo(); + const target = mergedFile(dir); + expect(runMerge(cfg(dir, target), collected.io)).toEqual( + err({ + code: "merged-file-is-layer" as const, + mergedFile: target, + index, + layer: join(dir, layer), + }), + ); + expect(collected.lines).toEqual([]); + expect(readFileSync(join(dir, "fleet.yml"), "utf8")).toBe(FLEET); + expect(readFileSync(join(dir, "repo.yml"), "utf8")).toBe(REPO); + }), ); }); diff --git a/test/flows/multi.test.ts b/test/flows/multi.test.ts index 0c3fa2ab..db60e2fc 100644 --- a/test/flows/multi.test.ts +++ b/test/flows/multi.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Decrypter, generateX25519Identity, identityToRecipient } from "age-encryption"; import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; import { SectionSelection } from "../../src/engine/section-selection.js"; import { runMulti } from "../../src/flows/multi.js"; import type { TargetOutcome } from "../../src/flows/redact.js"; -import { type Io, maskRegistry } from "../../src/io.js"; import { isPrivate } from "../../src/private.js"; import { describeProblem, type Problem } from "../../src/problem.js"; import { @@ -16,43 +14,9 @@ import { type ArtifactUploader, } from "../../src/report/artifact-report.js"; import { REPORT_HEADING } from "../../src/report/composer.js"; +import { captureIo } from "../io/capture.js"; import { MockApi } from "../mock-api.js"; - -function captureIo(): { - io: Io; - annotations: string[]; - logs: string[]; - masks: string[]; - events: string[]; -} { - const annotations: string[] = []; - const logs: string[] = []; - const masks: string[] = []; - const events: string[] = []; - return { - io: { - annotate: (level, message) => { - annotations.push(`${level}: ${message}`); - events.push(`annotate ${level}: ${message}`); - }, - log: (line) => { - logs.push(line); - events.push(`log: ${line}`); - }, - debug: () => {}, - summary: () => {}, - output: () => {}, - ...maskRegistry((value) => { - masks.push(value); - events.push(`mask: ${value}`); - }), - }, - annotations, - logs, - masks, - events, - }; -} +import { withTempDir } from "../temp-dir.js"; /** True when the target closed sealed: the redaction decision, read from the brand. */ const redacted = (target: TargetOutcome | undefined): boolean => @@ -105,16 +69,13 @@ describe("runMulti", () => { layout: Record, body: (dir: string) => Promise, ): Promise { - const dir = mkdtempSync(join(tmpdir(), "sac-multi-")); - try { + return withTempDir("sac-multi-", (dir) => { for (const [rel, content] of Object.entries(layout)) { mkdirSync(join(dir, rel, ".."), { recursive: true }); writeFileSync(join(dir, rel), content); } - return await body(dir); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + return body(dir); + }); } test("a remote target's own settings.yml is target-authored: its $NAME reference is refused", async () => { diff --git a/test/flows/redact.test.ts b/test/flows/redact.test.ts index 64d540f2..85606f10 100644 --- a/test/flows/redact.test.ts +++ b/test/flows/redact.test.ts @@ -15,6 +15,7 @@ import { } from "../../src/flows/redact.js"; import { type Io, maskRegistry, prefixedIo, silentIo } from "../../src/io.js"; import { isPrivate, markPrivate } from "../../src/private.js"; +import { captureIo } from "../io/capture.js"; /** A private-set predicate from a lowercase-keyed slug list. */ function privateSet(...slugs: string[]): (slug: string) => boolean { @@ -119,18 +120,6 @@ describe("planRedaction", () => { }); }); -/** An Io recording every public emission, for the channel and projection tests. */ -function recordingIo(): { io: Io; emitted: string[] } { - const emitted: string[] = []; - const io: Io = { - ...silentIo(), - ...maskRegistry((v) => emitted.push(`mask ${v}`)), - annotate: (level, message) => emitted.push(`${level}: ${message}`), - log: (line) => emitted.push(line), - }; - return { io, emitted }; -} - const outcomes = [ { key: "repository" as const, @@ -143,7 +132,7 @@ const outcomes = [ describe("target channels", () => { test("a public channel emits in the clear with the slug prefix and closes with its detail open", () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const channel = publicChannel(io, "o/pub", true); channel.io.annotate("error", "boom"); channel.io.log("changed"); @@ -151,10 +140,10 @@ describe("target channels", () => { "warning", "ignoring unknown section in o/pub:.github/settings.yml", ); - expect(emitted).toEqual([ - "error: o/pub: boom", - "o/pub: changed", - "warning: ignoring unknown section in o/pub:.github/settings.yml", + expect(events).toEqual([ + "annotate error: o/pub: boom", + "log: o/pub: changed", + "annotate warning: ignoring unknown section in o/pub:.github/settings.yml", ]); const detail = channel.close({ outcomes, note: "n" }); expect(isPrivate(detail)).toBe(false); @@ -163,13 +152,13 @@ describe("target channels", () => { }); test("a redacted channel captures every line and closes sealed with a transcript snapshot", () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const channel = redactedChannel(io, "o/priv", "private repository #1"); channel.io.annotate("error", "boom SECRET"); channel.unprefixed.annotate("warning", "ignoring SECRET"); channel.io.log("changed SECRET"); channel.io.mask("o/priv"); - expect(emitted).toEqual(["mask o/priv"]); + expect(events).toEqual(["mask: o/priv"]); const detail = channel.close({ outcomes }); // A line written after the close never reaches the sealed transcript. channel.io.log("late SECRET"); @@ -190,12 +179,12 @@ describe("target channels", () => { test("the plan opens a redacted channel for a hidden slug and a prefixed public one otherwise", () => { const plan = planRedaction("redact", ["o/pub", "o/priv"], [], privateSet("o/priv"), "a/r"); - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const pub = openTargetChannel(plan, io, "o/pub"); const priv = openTargetChannel(plan, io, "o/priv"); pub.io.log("visible"); priv.io.log("hidden"); - expect(emitted).toEqual(["o/pub: visible"]); + expect(events).toEqual(["log: o/pub: visible"]); expect(pub.display).toBe("o/pub"); expect(priv.display).toBe("private repository #1"); expect(isPrivate(pub.close({ outcomes: [] }))).toBe(false); @@ -209,10 +198,10 @@ describe("attempt", () => { const failed = (message: string) => ({ result: "failed" as const, message }); test("a crash on a redacted target is captured into its transcript and never emitted", async () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const channel = redactedChannel(io, "o/priv", "private repository #1"); expect(await attempt(channel, crash, failed)).toEqual({ result: "failed", message: violation }); - expect(emitted).toEqual([]); + expect(events).toEqual([]); expect(channel.close({ outcomes: [] })).toEqual( markPrivate({ slug: "o/priv", @@ -224,17 +213,17 @@ describe("attempt", () => { }); test("the same crash in the clear is annotated in full, and a success passes through", async () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const channel = publicChannel(io, "o/priv", true); await attempt(channel, crash, failed); - expect(emitted).toEqual([`error: o/priv: ${violation}`]); + expect(events).toEqual([`annotate error: o/priv: ${violation}`]); const ok = (): Promise> => Promise.resolve({ result: "failed", message: "not a crash" }); expect(await attempt(channel, ok, failed)).toEqual({ result: "failed", message: "not a crash", }); - expect(emitted).toHaveLength(1); + expect(events).toHaveLength(1); }); }); @@ -287,7 +276,7 @@ describe("public projections", () => { const rows = [ { key: "labels" as const, status: "snapshot" as const, detail: ["labels[hush]"] }, ]; - const { io } = recordingIo(); + const { io } = captureIo(); const hidden = redactedChannel(io, "o/priv", privatePlaceholder(1)); expect( publicDetail(hidden.close({ outcomes: rows, note: "written", file: "out/o/priv.yml" })), @@ -313,20 +302,20 @@ describe("public projections", () => { ["drift", "warning: private repository #1: drift - rulesets. "], ["skipped", "notice: private repository #1: skipped. "], ] as const)("emitRedactedResult on %s names only closed values", (result, head) => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); emitRedactedResult(io, "private repository #1", result, sealed); - expect(emitted).toEqual([`${head}${REDACTED_NOTE}`]); + expect(events).toEqual([`annotate ${head}${REDACTED_NOTE}`]); }); test("emitRedactedResult says nothing for a healthy result", () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); emitRedactedResult(io, "private repository #1", "applied", sealed); emitRedactedResult(io, "private repository #1", "clean", sealed); - expect(emitted).toEqual([]); + expect(events).toEqual([]); }); test("a sealed value cannot reach a public sink or a string template without a projection", () => { - const { io, emitted } = recordingIo(); + const { io, events } = captureIo(); const slug = markPrivate("o/priv"); // @ts-expect-error a Private is not a string: io.log cannot take it io.log(slug); @@ -338,23 +327,18 @@ describe("public projections", () => { io.summary(slug); // The runtime shape is an opaque box: even forced through, the slug text is not what a sink would print. expect(`${slug}`).not.toContain("o/priv"); - expect(emitted.join("\n")).not.toContain("o/priv"); + expect(events.join("\n")).not.toContain("o/priv"); }); }); describe("capturingIo", () => { test("suppresses public annotate/log but records them in order", () => { - const emitted: string[] = []; - const base: Io = { - ...silentIo(), - annotate: (level, message) => emitted.push(`annotate ${level}: ${message}`), - log: (line) => emitted.push(`log: ${line}`), - }; + const { io: base, events } = captureIo(); const { io, drain } = capturingIo(base); io.log("first"); io.annotate("warning", "second"); io.log("third"); - expect(emitted).toEqual([]); + expect(events).toEqual([]); expect(drain()).toEqual([ { line: "first" }, { level: "warning", line: "second" }, @@ -388,18 +372,11 @@ describe("capturingIo", () => { test("composes as capturingIo(prefixedIo(io, display)): capture is per-target, mask stays raw", () => { // capturingIo suppresses the wrapped sink entirely, so the prefix never reaches the base; each target owns its own buffer, so recorded lines need // no prefix. - const masks: string[] = []; - const emitted: string[] = []; - const base: Io = { - ...silentIo(), - ...maskRegistry((v) => masks.push(v)), - annotate: (l, m) => emitted.push(`${l}: ${m}`), - log: (line) => emitted.push(line), - }; + const { io: base, events, masks } = captureIo(); const { io, drain } = capturingIo(prefixedIo(base, "private repository #1: ")); io.log("changed a label"); io.mask("o/secret"); - expect(emitted).toEqual([]); + expect(events).toEqual(["mask: o/secret"]); expect(drain()).toEqual([{ line: "changed a label" }]); expect(masks).toEqual(["o/secret"]); }); diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index e5b2902a..3860abd7 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -7,7 +7,7 @@ * targets) ends as the documented result. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { existsSync, lstatSync, @@ -36,6 +36,7 @@ import { collectingIo, type Io } from "../../src/io.js"; import { isPrivate, markPrivate } from "../../src/private.js"; import type { SectionKey } from "../../src/schema.js"; import { MockApi } from "../mock-api.js"; +import { withTempDir } from "../temp-dir.js"; const repo = parseRepoSlug("o/r")._unsafeUnwrap(); @@ -78,18 +79,10 @@ export function hasSnapshotHeader(written: string, slug: string): boolean { return dated?.startsWith(prefix) === true && ISO_INSTANT.test(dated.slice(prefix.length)); } -let dir: string; -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "snapshot-flow-")); -}); -afterEach(() => { - rmSync(dir, { recursive: true, force: true }); -}); - type FileConfig = Extract; type DirConfig = Extract; -const fileCfg = (overrides: Partial = {}): FileConfig => +const fileCfg = (dir: string, overrides: Partial = {}): FileConfig => ({ form: "file", repo, @@ -101,7 +94,7 @@ const fileCfg = (overrides: Partial = {}): FileConfig => ...overrides, }) as FileConfig; -const dirCfg = (overrides: Partial = {}): DirConfig => +const dirCfg = (dir: string, overrides: Partial = {}): DirConfig => ({ form: "dir", snapshotDir: join(dir, "snapshots"), @@ -143,155 +136,165 @@ describe("hasSnapshotHeader", () => { }); describe("runSnapshot, file form", () => { - test("writes the document under its header and reports through the outputs, the log, and the summary", async () => { - const api = new MockApi(labelsRoute("o/r", [BUG, DOCS])); - const cfg = fileCfg(); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - const written = readFileSync(cfg.snapshotFile, "utf8"); - expect(hasSnapshotHeader(written, "o/r")).toBe(true); - expect(parseYaml(written)).toEqual(doc(BUG, DOCS)); - expect(api.mutations()).toEqual([]); - expect(collected.outputs).toEqual({ - result: "snapshot", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines).toEqual([ - { line: `snapshot written to ${cfg.snapshotFile}` }, - { line: "result: snapshot" }, - ]); - expect(collected.summary).toEqual([ - [ - "## github-settings-as-code (snapshot)", - "", - `:white_check_mark: snapshot - written to ${cfg.snapshotFile}`, - "", - "| Section | Status | Detail |", - "|---|---|---|", - "| labels | :white_check_mark: snapshot | - |", - ].join("\n"), - ]); - }); + test("writes the document under its header and reports through the outputs, the log, and the summary", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi(labelsRoute("o/r", [BUG, DOCS])); + const cfg = fileCfg(dir); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const written = readFileSync(cfg.snapshotFile, "utf8"); + expect(hasSnapshotHeader(written, "o/r")).toBe(true); + expect(parseYaml(written)).toEqual(doc(BUG, DOCS)); + expect(api.mutations()).toEqual([]); + expect(collected.outputs).toEqual({ + result: "snapshot", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines).toEqual([ + { line: `snapshot written to ${cfg.snapshotFile}` }, + { line: "result: snapshot" }, + ]); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot)", + "", + `:white_check_mark: snapshot - written to ${cfg.snapshotFile}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + ].join("\n"), + ]); + })); - test("a private target other than the run's own closes through the fleet's seal: the summary hides the note, the log says nothing", async () => { - const api = new MockApi({ - "GET /repos/o/r": { data: { private: true, visibility: "private" } }, - ...labelsRoute("o/r", [BUG]), - }); - const cfg = fileCfg({ privateRepos: "redact", selfSlug: "admin/fleet" }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); - expect([...collected.io.masked()]).toEqual(["o/r"]); - expect(collected.lines).toEqual([{ line: "result: snapshot" }]); - expect(collected.summary).toEqual([ - [ - "## github-settings-as-code (snapshot)", - "", - `:white_check_mark: snapshot - ${REDACTED_NOTE}`, - "", - "| Section | Status | Detail |", - "|---|---|---|", - "| labels | :white_check_mark: snapshot | hidden (private repository) |", - ].join("\n"), - ]); - expect(collected.summary[0]).not.toContain(cfg.snapshotFile); - }); + test("a private target other than the run's own closes through the fleet's seal: the summary hides the note, the log says nothing", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({ + "GET /repos/o/r": { data: { private: true, visibility: "private" } }, + ...labelsRoute("o/r", [BUG]), + }); + const cfg = fileCfg(dir, { privateRepos: "redact", selfSlug: "admin/fleet" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + expect([...collected.io.masked()]).toEqual(["o/r"]); + expect(collected.lines).toEqual([{ line: "result: snapshot" }]); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot)", + "", + `:white_check_mark: snapshot - ${REDACTED_NOTE}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | hidden (private repository) |", + ].join("\n"), + ]); + expect(collected.summary[0]).not.toContain(cfg.snapshotFile); + })); - test("a private target that fails gets the fleet's one closed-value line, numbered as a fleet of one", async () => { - // No labels route: the read answers 404, the denial that fails the target under the fail policy. - const api = new MockApi({ - "GET /repos/o/r": { data: { private: true, visibility: "private" } }, - }); - const cfg = fileCfg({ privateRepos: "redact", selfSlug: "admin/fleet" }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(existsSync(cfg.snapshotFile)).toBe(false); - expect(collected.lines).toEqual([ - { level: "error", line: `private repository #1: failed - labels. ${REDACTED_NOTE}` }, - { line: "result: failed" }, - ]); - expect(collected.summary[0]).toContain("| labels | :x: failed | hidden (private repository) |"); - expect(collected.summary[0]).not.toContain("/repos/o/r/labels"); - }); + test("a private target that fails gets the fleet's one closed-value line, numbered as a fleet of one", () => + withTempDir("snapshot-flow-", async (dir) => { + // No labels route: the read answers 404, the denial that fails the target under the fail policy. + const api = new MockApi({ + "GET /repos/o/r": { data: { private: true, visibility: "private" } }, + }); + const cfg = fileCfg(dir, { privateRepos: "redact", selfSlug: "admin/fleet" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(cfg.snapshotFile)).toBe(false); + expect(collected.lines).toEqual([ + { level: "error", line: `private repository #1: failed - labels. ${REDACTED_NOTE}` }, + { line: "result: failed" }, + ]); + expect(collected.summary[0]).toContain( + "| labels | :x: failed | hidden (private repository) |", + ); + expect(collected.summary[0]).not.toContain("/repos/o/r/labels"); + })); - test("a denied section is skipped under warn: partial, the file omits it, the header and the outputs say so", async () => { - // No variables route: the read answers 404, the fine-grained denial. - const api = new MockApi(labelsRoute("o/r", [BUG])); - const cfg = fileCfg({ - onMissingPermission: "warn", - sections: only("labels", "actions_variables", "check_suite_preferences"), - }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - const written = readFileSync(cfg.snapshotFile, "utf8"); - expect(parseYaml(written)).toEqual(doc(BUG)); - expect(written).toContain( - "# check_suite_preferences: GitHub exposes no read endpoint for this section, so there is nothing to snapshot; apply re-asserts the declared value on every run\n", - ); - expect(written).toMatch( - /^# actions_variables: the token was denied GET \/repos\/o\/r\/actions\/variables/m, - ); - expect(collected.outputs).toEqual({ - result: "partial", - "skipped-sections": "actions_variables", - "repos-result": "{}", - }); - expect(collected.lines.map((entry) => `${entry.level ?? "log"}: ${entry.line}`)).toEqual([ - expect.stringMatching(/^warning: actions_variables: skipped - the token was denied GET/), - "notice: not snapshotted: check_suite_preferences - snapshot does not read these sections back, so the file omits them (the header says why); declare them by hand if they should be managed", - `log: snapshot written to ${cfg.snapshotFile}`, - "log: result: partial", - ]); - expect(collected.summary[0]).toContain(":warning: partial - written to"); - expect(collected.summary[0]).toContain("| actions_variables | :fast_forward: skipped |"); - expect(collected.summary[0]).toContain( - "| check_suite_preferences | :fast_forward: unsupported |", - ); - }); + test("a denied section is skipped under warn: partial, the file omits it, the header and the outputs say so", () => + withTempDir("snapshot-flow-", async (dir) => { + // No variables route: the read answers 404, the fine-grained denial. + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(dir, { + onMissingPermission: "warn", + sections: only("labels", "actions_variables", "check_suite_preferences"), + }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const written = readFileSync(cfg.snapshotFile, "utf8"); + expect(parseYaml(written)).toEqual(doc(BUG)); + expect(written).toContain( + "# check_suite_preferences: GitHub exposes no read endpoint for this section, so there is nothing to snapshot; apply re-asserts the declared value on every run\n", + ); + expect(written).toMatch( + /^# actions_variables: the token was denied GET \/repos\/o\/r\/actions\/variables/m, + ); + expect(collected.outputs).toEqual({ + result: "partial", + "skipped-sections": "actions_variables", + "repos-result": "{}", + }); + expect(collected.lines.map((entry) => `${entry.level ?? "log"}: ${entry.line}`)).toEqual([ + expect.stringMatching(/^warning: actions_variables: skipped - the token was denied GET/), + "notice: not snapshotted: check_suite_preferences - snapshot does not read these sections back, so the file omits them (the header says why); declare them by hand if they should be managed", + `log: snapshot written to ${cfg.snapshotFile}`, + "log: result: partial", + ]); + expect(collected.summary[0]).toContain(":warning: partial - written to"); + expect(collected.summary[0]).toContain("| actions_variables | :fast_forward: skipped |"); + expect(collected.summary[0]).toContain( + "| check_suite_preferences | :fast_forward: unsupported |", + ); + })); - test("a denied section under fail fails the run and writes no file", async () => { - const api = new MockApi(labelsRoute("o/r", [BUG])); - const cfg = fileCfg({ sections: only("labels", "actions_variables") }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(existsSync(cfg.snapshotFile)).toBe(false); - expect(collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines).toEqual([ - { - level: "error", - line: expect.stringMatching( - /^actions_variables: not snapshotted - the token was denied GET/, - ), - }, - { line: "result: failed" }, - ]); - expect(collected.summary[0]).toContain( - ":x: failed - the snapshot failed, so no file was written", - ); - }); + test("a denied section under fail fails the run and writes no file", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(dir, { sections: only("labels", "actions_variables") }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(cfg.snapshotFile)).toBe(false); + expect(collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines).toEqual([ + { + level: "error", + line: expect.stringMatching( + /^actions_variables: not snapshotted - the token was denied GET/, + ), + }, + { line: "result: failed" }, + ]); + expect(collected.summary[0]).toContain( + ":x: failed - the snapshot failed, so no file was written", + ); + })); - test("an unwritable snapshot-file fails the run naming the input", async () => { - const api = new MockApi(labelsRoute("o/r", [BUG])); - writeFileSync(join(dir, "blocker"), ""); - const cfg = fileCfg({ snapshotFile: join(dir, "blocker", "snapshot.yml") }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(collected.outputs.result).toBe("failed"); - const [first] = collected.lines; - expect(first?.level).toBe("error"); - // The path is compared as text, never as a pattern; the OS error code sits between. - expect(first?.line.startsWith(`cannot write the snapshot to ${cfg.snapshotFile}: `)).toBe(true); - expect(first?.line).toMatch(/E(EXIST|NOTDIR)/); - expect( - first?.line.endsWith('. Check that the "snapshot-file" input names a writable path'), - ).toBe(true); - }); + test("an unwritable snapshot-file fails the run naming the input", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + writeFileSync(join(dir, "blocker"), ""); + const cfg = fileCfg(dir, { snapshotFile: join(dir, "blocker", "snapshot.yml") }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs.result).toBe("failed"); + const [first] = collected.lines; + expect(first?.level).toBe("error"); + // The path is compared as text, never as a pattern; the OS error code sits between. + expect(first?.line.startsWith(`cannot write the snapshot to ${cfg.snapshotFile}: `)).toBe( + true, + ); + expect(first?.line).toMatch(/E(EXIST|NOTDIR)/); + expect( + first?.line.endsWith('. Check that the "snapshot-file" input names a writable path'), + ).toBe(true); + })); }); function settingsRefusal(snapshotFile: string): string { @@ -318,151 +321,156 @@ describe("runSnapshot writes through a staging file", () => { line: `cannot write the snapshot to ${path}: ${os}. Check that the "${input}" input names a writable path`, }); - test("a staging write that fails leaves the previous snapshot intact and reports the write's error", async () => { - const api = new MockApi(labelsRoute("o/r", [BUG])); - const cfg = fileCfg(); - const staging = `${cfg.snapshotFile}.tmp`; - mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); - writeFileSync(cfg.snapshotFile, "labels: []\n"); - // A directory at the staging path fails the run before the rename; the run must not remove it either. - mkdirSync(staging); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines).toEqual([ - unwritable( - cfg.snapshotFile, - "snapshot-file", - `SystemError [ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) ${staging}`, - ), - { line: "result: failed" }, - ]); - expect(readFileSync(cfg.snapshotFile, "utf8")).toBe("labels: []\n"); - rmSync(staging, { recursive: true }); - expect(await run(api, cfg, collectingIo().io)).toBe(0); - expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); - expect(existsSync(staging)).toBe(false); - }); + test("a staging write that fails leaves the previous snapshot intact and reports the write's error", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(dir); + const staging = `${cfg.snapshotFile}.tmp`; + mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); + writeFileSync(cfg.snapshotFile, "labels: []\n"); + // A directory at the staging path fails the run before the rename; the run must not remove it either. + mkdirSync(staging); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines).toEqual([ + unwritable( + cfg.snapshotFile, + "snapshot-file", + `SystemError [ERR_FS_EISDIR]: Path is a directory: rm returned EISDIR (is a directory) ${staging}`, + ), + { line: "result: failed" }, + ]); + expect(readFileSync(cfg.snapshotFile, "utf8")).toBe("labels: []\n"); + rmSync(staging, { recursive: true }); + expect(await run(api, cfg, collectingIo().io)).toBe(0); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + expect(existsSync(staging)).toBe(false); + })); - test("a leftover link at the staging path is unlinked, never written through: the destination becomes a regular file", async () => { - const api = new MockApi(labelsRoute("o/r", [BUG])); - const cfg = fileCfg(); - const staging = `${cfg.snapshotFile}.tmp`; - mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); - writeFileSync(cfg.snapshotFile, "labels: []\n"); - symlinkSync("snapshot.yml", staging); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - expect(collected.outputs).toEqual({ - result: "snapshot", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines).toEqual([ - { line: `snapshot written to ${cfg.snapshotFile}` }, - { line: "result: snapshot" }, - ]); - expect(lstatSync(cfg.snapshotFile).isSymbolicLink()).toBe(false); - expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); - expect(existsSync(staging)).toBe(false); - }); + test("a leftover link at the staging path is unlinked, never written through: the destination becomes a regular file", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = fileCfg(dir); + const staging = `${cfg.snapshotFile}.tmp`; + mkdirSync(dirname(cfg.snapshotFile), { recursive: true }); + writeFileSync(cfg.snapshotFile, "labels: []\n"); + symlinkSync("snapshot.yml", staging); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + expect(collected.outputs).toEqual({ + result: "snapshot", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines).toEqual([ + { line: `snapshot written to ${cfg.snapshotFile}` }, + { line: "result: snapshot" }, + ]); + expect(lstatSync(cfg.snapshotFile).isSymbolicLink()).toBe(false); + expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG)); + expect(existsSync(staging)).toBe(false); + })); - test("a rename that fails removes the staging file, fails only that target, and leaves the destination as it was", async () => { - const api = new MockApi({ - "GET /repos/o/a": { data: { private: false } }, - "GET /repos/o/b": { data: { private: false } }, - ...labelsRoute("o/a", [BUG]), - ...labelsRoute("o/b", [DOCS]), - }); - const cfg = dirCfg(); - const fileA = join(cfg.snapshotDir, "o", "a.yml"); - const fileB = join(cfg.snapshotDir, "o", "b.yml"); - // A directory holding a file at o/a's destination lets the staging write succeed and the rename fail. - mkdirSync(fileA, { recursive: true }); - writeFileSync(join(fileA, "keep"), "authored\n"); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(collected.outputs).toEqual({ - "skipped-sections": "", - result: "failed", - "repos-result": JSON.stringify({ - "o/a": { result: "failed", source: "remote", "skipped-sections": [] }, - "o/b": { result: "snapshot", source: "remote", "skipped-sections": [] }, - }), - }); - const { level, line } = unwritable( - fileA, - "snapshot-dir", - `Error: EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, - ); - expect(collected.lines).toEqual([ - { level, line: `o/a: ${line}` }, - { line: `o/b: snapshot written to ${fileB}` }, - { line: "result: failed" }, - ]); - expect(existsSync(`${fileA}.tmp`)).toBe(false); - expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n"); - expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); - expect(existsSync(`${fileB}.tmp`)).toBe(false); - expect(collected.summary[0]?.split("\n").slice(0, 8)).toEqual([ - "## github-settings-as-code (snapshot, 2 repositories)", - "", - `1 of 2 snapshots written under ${cfg.snapshotDir}.`, - "", - "| Repository | Source | Result | File |", - "|---|---|---|---|", - "| o/a | remote | :x: failed | - |", - `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, - ]); - }); + test("a rename that fails removes the staging file, fails only that target, and leaves the destination as it was", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/b": { data: { private: false } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/b", [DOCS]), + }); + const cfg = dirCfg(dir); + const fileA = join(cfg.snapshotDir, "o", "a.yml"); + const fileB = join(cfg.snapshotDir, "o", "b.yml"); + // A directory holding a file at o/a's destination lets the staging write succeed and the rename fail. + mkdirSync(fileA, { recursive: true }); + writeFileSync(join(fileA, "keep"), "authored\n"); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "failed", + "repos-result": JSON.stringify({ + "o/a": { result: "failed", source: "remote", "skipped-sections": [] }, + "o/b": { result: "snapshot", source: "remote", "skipped-sections": [] }, + }), + }); + const { level, line } = unwritable( + fileA, + "snapshot-dir", + `Error: EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`, + ); + expect(collected.lines).toEqual([ + { level, line: `o/a: ${line}` }, + { line: `o/b: snapshot written to ${fileB}` }, + { line: "result: failed" }, + ]); + expect(existsSync(`${fileA}.tmp`)).toBe(false); + expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n"); + expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); + expect(existsSync(`${fileB}.tmp`)).toBe(false); + expect(collected.summary[0]?.split("\n").slice(0, 8)).toEqual([ + "## github-settings-as-code (snapshot, 2 repositories)", + "", + `1 of 2 snapshots written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + "| o/a | remote | :x: failed | - |", + `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, + ]); + })); }); describe("runSnapshot refuses a destination that would overwrite an authored file", () => { - test.each([ + test.each<[string, (dir: string) => SnapshotConfig, string]>([ [ "snapshot-file naming the settings file apply reads", - () => fileCfg({ snapshotFile: "./.github/settings.yml" }), + (dir) => fileCfg(dir, { snapshotFile: "./.github/settings.yml" }), settingsRefusal("./.github/settings.yml"), ], [ "snapshot-dir equal to the repos-dir", - () => dirCfg({ snapshotDir: "./repos", reposDir: "repos" }), + (dir) => dirCfg(dir, { snapshotDir: "./repos", reposDir: "repos" }), disjointRefusal("./repos", "repos"), ], [ "snapshot-dir above the repos-dir, where a bare .yml would be overwritten", - () => dirCfg({ snapshotDir: "central", reposDir: "central/acme" }), + (dir) => dirCfg(dir, { snapshotDir: "central", reposDir: "central/acme" }), disjointRefusal("central", "central/acme"), ], [ "snapshot-dir below the repos-dir under a name starting with two dots, which is still below it", - () => dirCfg({ snapshotDir: "central/..snapshots", reposDir: "central" }), + (dir) => dirCfg(dir, { snapshotDir: "central/..snapshots", reposDir: "central" }), disjointRefusal("central/..snapshots", "central"), ], [ "snapshot-dir below the repos-dir, where the next run would read the snapshots as central files", - () => dirCfg({ snapshotDir: "central/snapshots", reposDir: "central" }), + (dir) => dirCfg(dir, { snapshotDir: "central/snapshots", reposDir: "central" }), disjointRefusal("central/snapshots", "central"), ], - ])("%s fails before any API call or write", async (_case, cfg, message) => { - const api = new MockApi({}); - const collected = collectingIo(); - expect(await run(api, cfg(), collected.io)).toBe(1); - expect(api.calls).toEqual([]); - expect(collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines).toEqual([ - { level: "error", line: message }, - { line: "result: failed" }, - ]); - }); + ])("%s fails before any API call or write", (_case, cfg, message) => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({}); + const collected = collectingIo(); + expect(await run(api, cfg(dir), collected.io)).toBe(1); + expect(api.calls).toEqual([]); + expect(collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines).toEqual([ + { level: "error", line: message }, + { line: "result: failed" }, + ]); + }), + ); }); function tempFoldsCase(): boolean { @@ -482,12 +490,12 @@ describe("runSnapshot refuses a destination that is an authored path under anoth authored: string; /** Makes the alias reach the authored path; a case alias needs nothing. */ link?: (dir: string) => void; - cfg: () => SnapshotConfig; + cfg: (dir: string) => SnapshotConfig; message: string; }; /** Runs in `dir`, since the inputs and the settings-file constant resolve against the working directory. */ - async function refuses({ authored, link, cfg, message }: Alias): Promise { + async function refuses(dir: string, { authored, link, cfg, message }: Alias): Promise { const authoredPath = join(dir, authored); mkdirSync(dirname(authoredPath), { recursive: true }); writeFileSync(authoredPath, AUTHORED); @@ -500,7 +508,7 @@ describe("runSnapshot refuses a destination that is an authored path under anoth const previous = process.cwd(); process.chdir(dir); try { - expect(await run(api, cfg(), collected.io)).toBe(1); + expect(await run(api, cfg(dir), collected.io)).toBe(1); } finally { process.chdir(previous); } @@ -526,7 +534,7 @@ describe("runSnapshot refuses a destination that is an authored path under anoth "snapshot-file spelled .GITHUB/settings.yml", { authored: settingsFile, - cfg: () => fileCfg({ snapshotFile: join(".GITHUB", "settings.yml") }), + cfg: (dir) => fileCfg(dir, { snapshotFile: join(".GITHUB", "settings.yml") }), message: settingsRefusal(join(".GITHUB", "settings.yml")), }, ], @@ -534,7 +542,7 @@ describe("runSnapshot refuses a destination that is an authored path under anoth "snapshot-dir spelled CENTRAL over the repos-dir central", { authored: central, - cfg: () => dirCfg({ snapshotDir: "CENTRAL", reposDir: "central" }), + cfg: (dir) => dirCfg(dir, { snapshotDir: "CENTRAL", reposDir: "central" }), message: disjointRefusal("CENTRAL", "central"), }, ], @@ -542,11 +550,12 @@ describe("runSnapshot refuses a destination that is an authored path under anoth "a snapshot-dir that does not exist yet below the case-aliased repos-dir", { authored: central, - cfg: () => dirCfg({ snapshotDir: join("CENTRAL", "snapshots"), reposDir: "central" }), + cfg: (dir) => + dirCfg(dir, { snapshotDir: join("CENTRAL", "snapshots"), reposDir: "central" }), message: disjointRefusal(join("CENTRAL", "snapshots"), "central"), }, ], - ])("%s", (_case, alias) => refuses(alias)); + ])("%s", (_case, alias) => withTempDir("snapshot-flow-", (dir) => refuses(dir, alias))); }); test.each<[string, Alias]>([ @@ -555,7 +564,7 @@ describe("runSnapshot refuses a destination that is an authored path under anoth { authored: settingsFile, link: (cwd) => symlinkSync(join(cwd, settingsFile), join(cwd, "snapshot.yml")), - cfg: () => fileCfg({ snapshotFile: "snapshot.yml" }), + cfg: (dir) => fileCfg(dir, { snapshotFile: "snapshot.yml" }), message: settingsRefusal("snapshot.yml"), }, ], @@ -564,7 +573,7 @@ describe("runSnapshot refuses a destination that is an authored path under anoth { authored: central, link: (cwd) => symlinkSync(join(cwd, "central"), join(cwd, "mirror")), - cfg: () => dirCfg({ snapshotDir: "mirror", reposDir: "central" }), + cfg: (dir) => dirCfg(dir, { snapshotDir: "mirror", reposDir: "central" }), message: disjointRefusal("mirror", "central"), }, ], @@ -573,8 +582,8 @@ describe("runSnapshot refuses a destination that is an authored path under anoth { authored: central, link: (cwd) => symlinkSync(join(cwd, "central"), join(cwd, "mirror")), - cfg: () => - dirCfg({ snapshotDir: ["missing", "..", "mirror"].join(sep), reposDir: "central" }), + cfg: (dir) => + dirCfg(dir, { snapshotDir: ["missing", "..", "mirror"].join(sep), reposDir: "central" }), message: disjointRefusal(["missing", "..", "mirror"].join(sep), "central"), }, ], @@ -586,8 +595,12 @@ describe("runSnapshot refuses a destination that is an authored path under anoth mkdirSync(join(cwd, "out")); symlinkSync(join("..", "authored"), join(cwd, "out", "central")); }, - cfg: () => - dirCfg({ snapshotDir: "out", reposDir: join("out", "central"), adminOwner: "central" }), + cfg: (dir) => + dirCfg(dir, { + snapshotDir: "out", + reposDir: join("out", "central"), + adminOwner: "central", + }), message: disjointRefusal("out", join("out", "central")), }, ], @@ -599,16 +612,16 @@ describe("runSnapshot refuses a destination that is an authored path under anoth mkdirSync(join(cwd, ".github", "inner")); symlinkSync(join(".github", "inner"), join(cwd, "link")); }, - cfg: () => fileCfg({ snapshotFile: ["link", "..", "settings.yml"].join(sep) }), + cfg: (dir) => fileCfg(dir, { snapshotFile: ["link", "..", "settings.yml"].join(sep) }), message: settingsRefusal(["link", "..", "settings.yml"].join(sep)), }, ], - ])("%s", (_case, alias) => refuses(alias)); + ])("%s", (_case, alias) => withTempDir("snapshot-flow-", (dir) => refuses(dir, alias))); type Carried = { authored: string; link: (cwd: string) => void; - cfg: () => DirConfig; + cfg: (dir: string) => DirConfig; /** Every target the run resolves, central first, with the file it would write and why it must not. */ targets: Array<{ slug: string; @@ -634,8 +647,8 @@ describe("runSnapshot refuses a destination that is an authored path under anoth symlinkSync("out", join(cwd, "outlink")); symlinkSync(join("..", "authored"), join(cwd, "out", "central")); }, - cfg: () => - dirCfg({ + cfg: (dir) => + dirCfg(dir, { snapshotDir: "outlink", reposInput: "", reposDir: join("out", "central"), @@ -657,8 +670,8 @@ describe("runSnapshot refuses a destination that is an authored path under anoth { authored: settingsFile, link: () => {}, - cfg: () => - dirCfg({ snapshotDir: ".", reposInput: ".github/settings", privateRepos: "show" }), + cfg: (dir) => + dirCfg(dir, { snapshotDir: ".", reposInput: ".github/settings", privateRepos: "show" }), targets: [ { slug: ".github/settings", @@ -677,8 +690,13 @@ describe("runSnapshot refuses a destination that is an authored path under anoth mkdirSync(join(cwd, "central")); symlinkSync(join("..", "out", "o"), join(cwd, "central", "o")); }, - cfg: () => - dirCfg({ snapshotDir: "out", reposDir: "central", reposInput: "", privateRepos: "show" }), + cfg: (dir) => + dirCfg(dir, { + snapshotDir: "out", + reposDir: "central", + reposInput: "", + privateRepos: "show", + }), targets: [ { slug: "o/r", @@ -697,8 +715,8 @@ describe("runSnapshot refuses a destination that is an authored path under anoth mkdirSync(join(cwd, "out")); symlinkSync(join("..", "central", "o"), join(cwd, "out", "o")); }, - cfg: () => - dirCfg({ + cfg: (dir) => + dirCfg(dir, { snapshotDir: "out", reposDir: "central", reposInput: "o/x", @@ -720,315 +738,328 @@ describe("runSnapshot refuses a destination that is an authored path under anoth ], }, ], - ])("%s: those targets fail alone, before any read", async (_case, carried) => { - const authoredPath = join(dir, carried.authored); - mkdirSync(dirname(authoredPath), { recursive: true }); - writeFileSync(authoredPath, AUTHORED); - carried.link(dir); - const api = new MockApi({}); - const collected = collectingIo(); - const previous = process.cwd(); - process.chdir(dir); - try { - expect(await run(api, carried.cfg(), collected.io)).toBe(1); - } finally { - process.chdir(previous); - } - expect(api.calls).toEqual([]); - expect(collected.outputs).toEqual({ - "skipped-sections": "", - result: "failed", - "repos-result": JSON.stringify( - Object.fromEntries( - carried.targets.map((t) => [ - t.slug, - { result: "failed", source: t.source, "skipped-sections": [] }, - ]), + ])("%s: those targets fail alone, before any read", (_case, carried) => + withTempDir("snapshot-flow-", async (dir) => { + const authoredPath = join(dir, carried.authored); + mkdirSync(dirname(authoredPath), { recursive: true }); + writeFileSync(authoredPath, AUTHORED); + carried.link(dir); + const api = new MockApi({}); + const collected = collectingIo(); + const previous = process.cwd(); + process.chdir(dir); + try { + expect(await run(api, carried.cfg(dir), collected.io)).toBe(1); + } finally { + process.chdir(previous); + } + expect(api.calls).toEqual([]); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "failed", + "repos-result": JSON.stringify( + Object.fromEntries( + carried.targets.map((t) => [ + t.slug, + { result: "failed", source: t.source, "skipped-sections": [] }, + ]), + ), ), - ), - }); - expect(collected.lines).toEqual([ - ...carried.targets.map((t) => ({ - level: "error" as const, - line: `${t.slug}: cannot write the snapshot to ${t.path}: ${t.reason(dir)}`, - })), - { line: "result: failed" }, - ]); - expect(readFileSync(authoredPath, "utf8")).toBe(AUTHORED); - for (const t of carried.targets) { - expect(existsSync(join(dir, `${t.path}.tmp`))).toBe(false); - } - }); + }); + expect(collected.lines).toEqual([ + ...carried.targets.map((t) => ({ + level: "error" as const, + line: `${t.slug}: cannot write the snapshot to ${t.path}: ${t.reason(dir)}`, + })), + { line: "result: failed" }, + ]); + expect(readFileSync(authoredPath, "utf8")).toBe(AUTHORED); + for (const t of carried.targets) { + expect(existsSync(join(dir, `${t.path}.tmp`))).toBe(false); + } + }), + ); }); describe("runSnapshot, dir form", () => { - test("a snapshot-dir spelled through a link and .. lands where join puts it, beside the link, and is not refused", async () => { - // The write collapses "link/.." before the OS sees it; the guard must judge the same place, not link's target. - mkdirSync(join(dir, "central", "o"), { recursive: true }); - mkdirSync(join(dir, "central", "inner")); - writeFileSync(join(dir, "central", "o", "r.yml"), "labels: []\n"); - symlinkSync(join("central", "inner"), join(dir, "link")); - const api = new MockApi(labelsRoute("o/r", [BUG])); - const cfg = dirCfg({ - snapshotDir: ["link", "..", "snapshots"].join(sep), - reposDir: "central", - reposInput: "", - privateRepos: "show", - }); - const collected = collectingIo(); - const previous = process.cwd(); - process.chdir(dir); - try { - expect(await run(api, cfg, collected.io)).toBe(0); - } finally { - process.chdir(previous); - } - expect(parseYaml(readFileSync(join(dir, "snapshots", "o", "r.yml"), "utf8"))).toEqual(doc(BUG)); - expect(existsSync(join(dir, "central", "snapshots"))).toBe(false); - expect(readFileSync(join(dir, "central", "o", "r.yml"), "utf8")).toBe("labels: []\n"); - expect(collected.outputs).toEqual({ - "skipped-sections": "", - result: "snapshot", - "repos-result": JSON.stringify({ - "o/r": { result: "snapshot", source: "central", "skipped-sections": [] }, - }), - }); - expect(collected.lines).toEqual([ - { line: `o/r: snapshot written to ${join(cfg.snapshotDir, "o", "r.yml")}` }, - { line: "result: snapshot" }, - ]); - }); + test("a snapshot-dir spelled through a link and .. lands where join puts it, beside the link, and is not refused", () => + withTempDir("snapshot-flow-", async (dir) => { + // The write collapses "link/.." before the OS sees it; the guard must judge the same place, not link's target. + mkdirSync(join(dir, "central", "o"), { recursive: true }); + mkdirSync(join(dir, "central", "inner")); + writeFileSync(join(dir, "central", "o", "r.yml"), "labels: []\n"); + symlinkSync(join("central", "inner"), join(dir, "link")); + const api = new MockApi(labelsRoute("o/r", [BUG])); + const cfg = dirCfg(dir, { + snapshotDir: ["link", "..", "snapshots"].join(sep), + reposDir: "central", + reposInput: "", + privateRepos: "show", + }); + const collected = collectingIo(); + const previous = process.cwd(); + process.chdir(dir); + try { + expect(await run(api, cfg, collected.io)).toBe(0); + } finally { + process.chdir(previous); + } + expect(parseYaml(readFileSync(join(dir, "snapshots", "o", "r.yml"), "utf8"))).toEqual( + doc(BUG), + ); + expect(existsSync(join(dir, "central", "snapshots"))).toBe(false); + expect(readFileSync(join(dir, "central", "o", "r.yml"), "utf8")).toBe("labels: []\n"); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "snapshot", + "repos-result": JSON.stringify({ + "o/r": { result: "snapshot", source: "central", "skipped-sections": [] }, + }), + }); + expect(collected.lines).toEqual([ + { line: `o/r: snapshot written to ${join(cfg.snapshotDir, "o", "r.yml")}` }, + { line: "result: snapshot" }, + ]); + })); - test("writes one /.yml per resolved target and publishes the per-target rollup", async () => { - const api = new MockApi({ - "GET /repos/o/a": { data: { private: false } }, - "GET /repos/o/b": { data: { private: false } }, - ...labelsRoute("o/a", [BUG]), - ...labelsRoute("o/b", [DOCS]), - }); - const cfg = dirCfg(); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - const fileA = join(cfg.snapshotDir, "o", "a.yml"); - const fileB = join(cfg.snapshotDir, "o", "b.yml"); - expect(hasSnapshotHeader(readFileSync(fileA, "utf8"), "o/a")).toBe(true); - expect(parseYaml(readFileSync(fileA, "utf8"))).toEqual(doc(BUG)); - expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); - expect(api.mutations()).toEqual([]); - expect(collected.outputs).toEqual({ - "skipped-sections": "", - result: "snapshot", - "repos-result": JSON.stringify({ - "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, - "o/b": { result: "snapshot", source: "remote", "skipped-sections": [] }, - }), - }); - expect(collected.lines).toEqual([ - { line: `o/a: snapshot written to ${fileA}` }, - { line: `o/b: snapshot written to ${fileB}` }, - { line: "result: snapshot" }, - ]); - expect(collected.summary).toEqual([ - [ - "## github-settings-as-code (snapshot, 2 repositories)", - "", - `Snapshots written under ${cfg.snapshotDir}.`, - "", - "| Repository | Source | Result | File |", - "|---|---|---|---|", - `| o/a | remote | :white_check_mark: snapshot | ${fileA} |`, - `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, - "", - "### o/a (snapshot)", - "", - `written to ${fileA}`, - "", - "| Section | Status | Detail |", - "|---|---|---|", - "| labels | :white_check_mark: snapshot | - |", - "", - "### o/b (snapshot)", - "", - `written to ${fileB}`, - "", - "| Section | Status | Detail |", - "|---|---|---|", - "| labels | :white_check_mark: snapshot | - |", - ].join("\n"), - ]); - }); + test("writes one /.yml per resolved target and publishes the per-target rollup", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/b": { data: { private: false } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/b", [DOCS]), + }); + const cfg = dirCfg(dir); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + const fileA = join(cfg.snapshotDir, "o", "a.yml"); + const fileB = join(cfg.snapshotDir, "o", "b.yml"); + expect(hasSnapshotHeader(readFileSync(fileA, "utf8"), "o/a")).toBe(true); + expect(parseYaml(readFileSync(fileA, "utf8"))).toEqual(doc(BUG)); + expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS)); + expect(api.mutations()).toEqual([]); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "snapshot", + "repos-result": JSON.stringify({ + "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, + "o/b": { result: "snapshot", source: "remote", "skipped-sections": [] }, + }), + }); + expect(collected.lines).toEqual([ + { line: `o/a: snapshot written to ${fileA}` }, + { line: `o/b: snapshot written to ${fileB}` }, + { line: "result: snapshot" }, + ]); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot, 2 repositories)", + "", + `Snapshots written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + `| o/a | remote | :white_check_mark: snapshot | ${fileA} |`, + `| o/b | remote | :white_check_mark: snapshot | ${fileB} |`, + "", + "### o/a (snapshot)", + "", + `written to ${fileA}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + "", + "### o/b (snapshot)", + "", + `written to ${fileB}`, + "", + "| Section | Status | Detail |", + "|---|---|---|", + "| labels | :white_check_mark: snapshot | - |", + ].join("\n"), + ]); + })); - test("a redacted target's values reach its file and nothing else: the slug is masked, the public view shows the placeholder", async () => { - const api = new MockApi({ - "GET /repos/o/a": { data: { private: false } }, - "GET /repos/o/p": { data: { private: true, visibility: "private" } }, - ...labelsRoute("o/a", [BUG]), - ...labelsRoute("o/p", [{ name: "secret-project", color: "000000", description: "hush" }]), - }); - const cfg = dirCfg({ reposInput: "o/a,o/p" }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(0); - expect(parseYaml(readFileSync(join(cfg.snapshotDir, "o", "p.yml"), "utf8"))).toEqual( - doc({ name: "secret-project", color: "000000", description: "hush" }), - ); - expect([...collected.io.masked()]).toEqual(["o/p"]); - const publicText = [ - ...collected.lines.map((entry) => entry.line), - ...collected.summary, - ...Object.values(collected.outputs), - ].join("\n"); - for (const needle of ["o/p", "p.yml", "secret-project", "hush"]) { - expect(publicText, `"${needle}" reached a public surface`).not.toContain(needle); - } - expect(collected.outputs).toEqual({ - result: "snapshot", - "skipped-sections": "", - "repos-result": JSON.stringify({ - "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, - "private repository #1": { result: "snapshot", source: "remote", "skipped-sections": [] }, - }), - }); - expect(collected.summary[0]).toContain( - "| private repository #1 | remote | :white_check_mark: snapshot | hidden (private repository) |", - ); - expect(collected.summary[0]).toContain( - "### private repository #1 (snapshot)\n\ndetails hidden: the repository is private or internal.", - ); - expect(collected.summary[0]).toContain( - "| labels | :white_check_mark: snapshot | hidden (private repository) |", - ); - }); + test("a redacted target's values reach its file and nothing else: the slug is masked, the public view shows the placeholder", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/p": { data: { private: true, visibility: "private" } }, + ...labelsRoute("o/a", [BUG]), + ...labelsRoute("o/p", [{ name: "secret-project", color: "000000", description: "hush" }]), + }); + const cfg = dirCfg(dir, { reposInput: "o/a,o/p" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(0); + expect(parseYaml(readFileSync(join(cfg.snapshotDir, "o", "p.yml"), "utf8"))).toEqual( + doc({ name: "secret-project", color: "000000", description: "hush" }), + ); + expect([...collected.io.masked()]).toEqual(["o/p"]); + const publicText = [ + ...collected.lines.map((entry) => entry.line), + ...collected.summary, + ...Object.values(collected.outputs), + ].join("\n"); + for (const needle of ["o/p", "p.yml", "secret-project", "hush"]) { + expect(publicText, `"${needle}" reached a public surface`).not.toContain(needle); + } + expect(collected.outputs).toEqual({ + result: "snapshot", + "skipped-sections": "", + "repos-result": JSON.stringify({ + "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, + "private repository #1": { result: "snapshot", source: "remote", "skipped-sections": [] }, + }), + }); + expect(collected.summary[0]).toContain( + "| private repository #1 | remote | :white_check_mark: snapshot | hidden (private repository) |", + ); + expect(collected.summary[0]).toContain( + "### private repository #1 (snapshot)\n\ndetails hidden: the repository is private or internal.", + ); + expect(collected.summary[0]).toContain( + "| labels | :white_check_mark: snapshot | hidden (private repository) |", + ); + })); - test("a redacted target that fails closes sealed with its transcript and speaks the fleet's one closed-value line", async () => { - // No labels route for o/p: the read answers 404, the denial that fails the target under the fail policy. - const api = new MockApi({ - "GET /repos/o/a": { data: { private: false } }, - "GET /repos/o/p": { data: { private: true, visibility: "private" } }, - ...labelsRoute("o/a", [BUG]), - }); - const cfg = dirCfg({ reposInput: "o/a,o/p" }); - const collected = collectingIo(); - const finished = (await runSnapshot(api, cfg, collected.io))._unsafeUnwrap(); - const hidden = finished.form === "dir" ? finished.targets[1] : undefined; - expect(hidden?.display).toBe("private repository #1"); - expect(isPrivate(hidden?.detail)).toBe(true); - // The engine's denial line was captured, not emitted: it travels sealed beside the outcome and the note. - expect(hidden?.detail).toEqual( - markPrivate({ - slug: "o/p", - outcomes: [ - { - key: "labels", - status: "failed", - detail: [expect.stringMatching(/^the token was denied GET \/repos\/o\/p\/labels/)], - }, - ], - note: "the snapshot failed, so no file was written", - file: undefined, - transcript: [ - { - level: "error", - line: expect.stringMatching( - /^labels: not snapshotted - the token was denied GET \/repos\/o\/p\/labels/, - ), - }, - ], - }), - ); - expect(concludeSnapshot(collected.io, finished)).toBe(1); - expect(existsSync(join(cfg.snapshotDir, "o", "p.yml"))).toBe(false); - expect(collected.lines).toEqual([ - { line: `o/a: snapshot written to ${join(cfg.snapshotDir, "o", "a.yml")}` }, - { level: "error", line: `private repository #1: failed - labels. ${REDACTED_NOTE}` }, - { line: "result: failed" }, - ]); - // Nothing was written, so the File column says so in the clear: an absent file is not a value the seal hides. - expect(collected.summary[0]).toContain("| private repository #1 | remote | :x: failed | - |"); - expect(collected.summary[0]).toContain("| labels | :x: failed | hidden (private repository) |"); - const publicText = [ - ...collected.lines.map((entry) => entry.line), - ...collected.summary, - ...Object.values(collected.outputs), - ].join("\n"); - expect(publicText).not.toContain("/repos/o/p/labels"); - expect(publicText).not.toContain("o/p.yml"); - }); + test("a redacted target that fails closes sealed with its transcript and speaks the fleet's one closed-value line", () => + withTempDir("snapshot-flow-", async (dir) => { + // No labels route for o/p: the read answers 404, the denial that fails the target under the fail policy. + const api = new MockApi({ + "GET /repos/o/a": { data: { private: false } }, + "GET /repos/o/p": { data: { private: true, visibility: "private" } }, + ...labelsRoute("o/a", [BUG]), + }); + const cfg = dirCfg(dir, { reposInput: "o/a,o/p" }); + const collected = collectingIo(); + const finished = (await runSnapshot(api, cfg, collected.io))._unsafeUnwrap(); + const hidden = finished.form === "dir" ? finished.targets[1] : undefined; + expect(hidden?.display).toBe("private repository #1"); + expect(isPrivate(hidden?.detail)).toBe(true); + // The engine's denial line was captured, not emitted: it travels sealed beside the outcome and the note. + expect(hidden?.detail).toEqual( + markPrivate({ + slug: "o/p", + outcomes: [ + { + key: "labels", + status: "failed", + detail: [expect.stringMatching(/^the token was denied GET \/repos\/o\/p\/labels/)], + }, + ], + note: "the snapshot failed, so no file was written", + file: undefined, + transcript: [ + { + level: "error", + line: expect.stringMatching( + /^labels: not snapshotted - the token was denied GET \/repos\/o\/p\/labels/, + ), + }, + ], + }), + ); + expect(concludeSnapshot(collected.io, finished)).toBe(1); + expect(existsSync(join(cfg.snapshotDir, "o", "p.yml"))).toBe(false); + expect(collected.lines).toEqual([ + { line: `o/a: snapshot written to ${join(cfg.snapshotDir, "o", "a.yml")}` }, + { level: "error", line: `private repository #1: failed - labels. ${REDACTED_NOTE}` }, + { line: "result: failed" }, + ]); + // Nothing was written, so the File column says so in the clear: an absent file is not a value the seal hides. + expect(collected.summary[0]).toContain("| private repository #1 | remote | :x: failed | - |"); + expect(collected.summary[0]).toContain( + "| labels | :x: failed | hidden (private repository) |", + ); + const publicText = [ + ...collected.lines.map((entry) => entry.line), + ...collected.summary, + ...Object.values(collected.outputs), + ].join("\n"); + expect(publicText).not.toContain("/repos/o/p/labels"); + expect(publicText).not.toContain("o/p.yml"); + })); - test("a name that would leave the directory fails its target alone; the rest of the fleet is written", async () => { - const api = new MockApi({ ...labelsRoute("o/a", [BUG]) }); - const cfg = dirCfg({ reposInput: "../escape,o/a", privateRepos: "show" }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(existsSync(join(dir, "escape.yml"))).toBe(false); - expect(existsSync(join(cfg.snapshotDir, "o", "a.yml"))).toBe(true); - expect(collected.lines[0]).toEqual({ - level: "error", - line: `../escape: the repository name "../escape" is not a GitHub owner/name (a "." or ".." segment), so it has no file under ${cfg.snapshotDir}`, - }); - expect(collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": JSON.stringify({ - "../escape": { result: "failed", source: "remote", "skipped-sections": [] }, - "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, - }), - }); - }); + test("a name that would leave the directory fails its target alone; the rest of the fleet is written", () => + withTempDir("snapshot-flow-", async (dir) => { + const api = new MockApi({ ...labelsRoute("o/a", [BUG]) }); + const cfg = dirCfg(dir, { reposInput: "../escape,o/a", privateRepos: "show" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(join(dir, "escape.yml"))).toBe(false); + expect(existsSync(join(cfg.snapshotDir, "o", "a.yml"))).toBe(true); + expect(collected.lines[0]).toEqual({ + level: "error", + line: `../escape: the repository name "../escape" is not a GitHub owner/name (a "." or ".." segment), so it has no file under ${cfg.snapshotDir}`, + }); + expect(collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": JSON.stringify({ + "../escape": { result: "failed", source: "remote", "skipped-sections": [] }, + "o/a": { result: "snapshot", source: "remote", "skipped-sections": [] }, + }), + }); + })); - test("a fleet whose every target fails writes nothing and the summary says so", async () => { - // No labels route: the read answers 404, the denial that fails the target under the fail policy. - const api = new MockApi({}); - const cfg = dirCfg({ reposInput: "o/a", privateRepos: "show" }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(existsSync(cfg.snapshotDir)).toBe(false); - expect(collected.outputs).toEqual({ - "skipped-sections": "", - result: "failed", - "repos-result": JSON.stringify({ - "o/a": { result: "failed", source: "remote", "skipped-sections": [] }, - }), - }); - expect(collected.summary).toEqual([ - [ - "## github-settings-as-code (snapshot, 1 repositories)", - "", - `No snapshot was written under ${cfg.snapshotDir}.`, - "", - "| Repository | Source | Result | File |", - "|---|---|---|---|", - "| o/a | remote | :x: failed | - |", - "", - "### o/a (failed)", - "", - "the snapshot failed, so no file was written", - "", - "| Section | Status | Detail |", - "|---|---|---|", - `| labels | :x: failed | the token was denied GET /repos/o/a/labels: 404 Not Found (a 404 here can also mean the resource does not exist). To fix, grant "Issues" (read and write) under the PAT's Repository permissions |`, - ].join("\n"), - ]); - }); + test("a fleet whose every target fails writes nothing and the summary says so", () => + withTempDir("snapshot-flow-", async (dir) => { + // No labels route: the read answers 404, the denial that fails the target under the fail policy. + const api = new MockApi({}); + const cfg = dirCfg(dir, { reposInput: "o/a", privateRepos: "show" }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(existsSync(cfg.snapshotDir)).toBe(false); + expect(collected.outputs).toEqual({ + "skipped-sections": "", + result: "failed", + "repos-result": JSON.stringify({ + "o/a": { result: "failed", source: "remote", "skipped-sections": [] }, + }), + }); + expect(collected.summary).toEqual([ + [ + "## github-settings-as-code (snapshot, 1 repositories)", + "", + `No snapshot was written under ${cfg.snapshotDir}.`, + "", + "| Repository | Source | Result | File |", + "|---|---|---|---|", + "| o/a | remote | :x: failed | - |", + "", + "### o/a (failed)", + "", + "the snapshot failed, so no file was written", + "", + "| Section | Status | Detail |", + "|---|---|---|", + `| labels | :x: failed | the token was denied GET /repos/o/a/labels: 404 Not Found (a 404 here can also mean the resource does not exist). To fix, grant "Issues" (read and write) under the PAT's Repository permissions |`, + ].join("\n"), + ]); + })); - test("a fleet that resolves to no targets is fatal before any file is written", async () => { - const empty = join(dir, "repos"); - mkdirSync(empty); - const api = new MockApi({}); - const cfg = dirCfg({ reposInput: "", reposDir: empty }); - const collected = collectingIo(); - expect(await run(api, cfg, collected.io)).toBe(1); - expect(api.calls).toEqual([]); - expect(existsSync(join(dir, "snapshots"))).toBe(false); - expect(collected.outputs).toEqual({ - result: "failed", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(collected.lines[0]).toEqual({ - level: "error", - line: expect.stringMatching( - /^multi-repo mode found no targets: repos-dir yielded no settings files/, - ), - }); - }); + test("a fleet that resolves to no targets is fatal before any file is written", () => + withTempDir("snapshot-flow-", async (dir) => { + const empty = join(dir, "repos"); + mkdirSync(empty); + const api = new MockApi({}); + const cfg = dirCfg(dir, { reposInput: "", reposDir: empty }); + const collected = collectingIo(); + expect(await run(api, cfg, collected.io)).toBe(1); + expect(api.calls).toEqual([]); + expect(existsSync(join(dir, "snapshots"))).toBe(false); + expect(collected.outputs).toEqual({ + result: "failed", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(collected.lines[0]).toEqual({ + level: "error", + line: expect.stringMatching( + /^multi-repo mode found no targets: repos-dir yielded no settings files/, + ), + }); + })); }); diff --git a/test/io/capture.ts b/test/io/capture.ts new file mode 100644 index 00000000..00b3f6c2 --- /dev/null +++ b/test/io/capture.ts @@ -0,0 +1,57 @@ +/** + * The one Io capture fake for tests: every channel lands in its own list and in one ordered event log + * (`annotate : `, `log: `, `summary: `, `output =`, + * `mask: `). Nothing is redacted (collectingIo does that); the debug trace is dropped. + */ + +import { type Io, maskRegistry, type OutputName } from "../../src/io.js"; + +export interface CapturedIo { + io: Io; + /** `: ` per annotate call. */ + annotations: string[]; + logs: string[]; + outputs: Partial>; + /** Every value registered through mask, in call order; io.masked() is the live set. */ + masks: string[]; + events: string[]; +} + +/** `onMask` runs at each mask call, for a test pinning what else has happened by the time a value is masked. */ +export function captureIo(onMask: (value: string) => void = () => {}): CapturedIo { + const captured: Omit = { + annotations: [], + logs: [], + outputs: {}, + masks: [], + events: [], + }; + const { annotations, logs, outputs, masks, events } = captured; + return { + io: { + annotate: (level, message) => { + annotations.push(`${level}: ${message}`); + events.push(`annotate ${level}: ${message}`); + }, + log: (line) => { + logs.push(line); + events.push(`log: ${line}`); + }, + debug: () => {}, + summary: (markdown) => { + // Coerced first: a test forcing an opaque Private box through the port must not crash the fake. + events.push(`summary: ${`${markdown}`.split("\n")[0]}`); + }, + output: (name, value) => { + outputs[name] = value; + events.push(`output ${name}=${value}`); + }, + ...maskRegistry((value) => { + masks.push(value); + events.push(`mask: ${value}`); + onMask(value); + }), + }, + ...captured, + }; +} diff --git a/test/io/mask-pair.test.ts b/test/io/mask-pair.test.ts index 31f923a8..08386ee9 100644 --- a/test/io/mask-pair.test.ts +++ b/test/io/mask-pair.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { type Io, type MaskPair, maskRegistry } from "../../src/io.js"; +import { captureIo } from "./capture.js"; const channels: Omit = { annotate: () => {}, @@ -30,10 +31,9 @@ describe("the Io mask pair", () => { forgedMasked.mask("o/private"); expect(forgedMasked.masked().size).toBe(0); - const forwarded: string[] = []; - const io: Io = { ...channels, ...maskRegistry((value) => forwarded.push(value)) }; + const { io, masks } = captureIo(); io.mask("o/private"); - expect(forwarded).toEqual(["o/private"]); + expect(masks).toEqual(["o/private"]); expect(io.masked().has("o/private")).toBe(true); }); diff --git a/test/library/public-surface.test.ts b/test/library/public-surface.test.ts index 29374c05..c4d628ed 100644 --- a/test/library/public-surface.test.ts +++ b/test/library/public-surface.test.ts @@ -8,8 +8,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseSync } from "oxc-parser"; import { ARCHITECTURE_PATH, lintArchitecture } from "../../.github/scripts/arch-lint.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const ENTRY = "src/index.ts"; /** Every name src/index.ts exports, runtime and type-only alike, sorted. */ diff --git a/test/published-schema.test.ts b/test/published-schema.test.ts index ece70f35..08320605 100644 --- a/test/published-schema.test.ts +++ b/test/published-schema.test.ts @@ -10,8 +10,8 @@ import { ok } from "neverthrow"; import { validateSectionShapes } from "../src/engine/validate.js"; import { SettingsFile, UNDECLARED_POLICY_SECTIONS } from "../src/schema.js"; import { FLAG_PAIRING_FIXTURES } from "./fixtures/environment-flag-pairing.js"; +import { ROOT } from "./root.js"; -const ROOT = join(import.meta.dir, ".."); const schema = JSON.parse(readFileSync(join(ROOT, "lib", "settings.schema.json"), "utf8")) as { $id?: string; definitions: Record>; diff --git a/test/report/delivery.test.ts b/test/report/delivery.test.ts index 691326bf..0a4295dd 100644 --- a/test/report/delivery.test.ts +++ b/test/report/delivery.test.ts @@ -8,7 +8,7 @@ import { } from "../../src/engine/orchestrate.js"; import { runOutcome } from "../../src/flows/deliver.js"; import { redactedChannel } from "../../src/flows/redact.js"; -import { type Io, maskRegistry, silentIo } from "../../src/io.js"; +import { type Io, silentIo } from "../../src/io.js"; import { isPrivate, type Private } from "../../src/private.js"; import { describeProblem } from "../../src/problem.js"; import type { ArtifactUploader } from "../../src/report/artifact-report.js"; @@ -22,6 +22,7 @@ import { } from "../../src/report/delivery.js"; import { MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import type { SettingsFile } from "../../src/schema.js"; +import { captureIo } from "../io/capture.js"; import { MockApi } from "../mock-api.js"; const MARKER = "settings-as-code-report"; @@ -36,18 +37,6 @@ const DRIFT: SectionOutcome[] = [ { key: "repository", status: "drift", detail: ["description: CANARY-live -> CANARY-want"] }, ]; -function recordingIo(): { io: Io; annotations: string[] } { - const annotations: string[] = []; - return { - io: { - ...silentIo(), - annotate: (level, message) => annotations.push(`${level}: ${message}`), - ...maskRegistry(() => {}), - }, - annotations, - }; -} - /** A redacted target's sealed detail, closed through the real channel so the transcript is genuine. */ function sealed(slug: string, outcomes: SectionOutcome[]): Private { const channel = redactedChannel(silentIo(), slug, "private repository #1"); @@ -99,7 +88,7 @@ function open(api: MockApi, channel: PrivateReportChannel, io: Io, uploader?: Ar describe("the issue channel", () => { test("delivers the full unredacted report into the target's issue and opens it when the target's exit is 1", async () => { const api = issueApi(); - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); const channel = open(api, "issue", io); await channel?.deliver(target("o/priv", 1)); await channel?.flush(); @@ -121,7 +110,7 @@ describe("the issue channel", () => { error: { status: 403, message: "Resource not accessible", body: "" }, }, }); - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); await open(api, "issue", io)?.deliver(target("o/priv", 1)); expect(annotations).toEqual([ "warning: private repository #1: could not deliver the private report (HTTP 403). To fix, " + @@ -132,7 +121,7 @@ describe("the issue channel", () => { test("a target whose slug did not parse gets one safe warning and no API traffic", async () => { const api = issueApi(); - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); await open(api, "issue", io)?.deliver({ ...target("o/priv", 1), repo: null }); expect(api.calls).toEqual([]); expect(annotations).toEqual([ @@ -144,7 +133,7 @@ describe("the issue channel", () => { const api = issueApi({ [`GET /repos/o/priv/issues?state=open&labels=${MARKER}&per_page=100&page=1`]: { data: [] }, }); - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); await open(api, "issue-on-failure", io)?.deliver({ ...target("o/priv", 0), conclusion: runOutcome([{ result: "clean" }], true), @@ -177,7 +166,7 @@ describe("the artifact channel", () => { test("accumulates every report and uploads ONE document on flush, each report under its placeholder heading", async () => { const { recipient, uploader, uploads, decrypt } = await harness(); - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); const channel = openReportChannel(new MockApi({}), "artifact", META, recipient, io, uploader); await channel?.deliver(target("o/a", 1)); // The channel never addresses the target repository, so it mirrors even a target whose slug failed to parse. @@ -218,7 +207,7 @@ describe("the artifact channel", () => { throw new Error("Unable to get the ACTIONS_RUNTIME_TOKEN env variable"); }, }; - const { io, annotations } = recordingIo(); + const { io, annotations } = captureIo(); const channel = openReportChannel(new MockApi({}), "artifact", META, recipient, io, uploader); await channel?.deliver(target("o/priv", 1)); await channel?.flush(); diff --git a/test/root.ts b/test/root.ts new file mode 100644 index 00000000..aaa130cd --- /dev/null +++ b/test/root.ts @@ -0,0 +1,5 @@ +/** The repository root, for tests that read committed files or spawn the scripts by path. */ + +import { join } from "node:path"; + +export const ROOT = join(import.meta.dir, ".."); diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts index e44c28bc..cfcd0444 100644 --- a/test/scripts/changed-sections.test.ts +++ b/test/scripts/changed-sections.test.ts @@ -1,6 +1,5 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { describe, expect, test } from "bun:test"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { ALL_SELECTING_PREFIXES, @@ -13,9 +12,10 @@ import { sectionsForFiles, } from "../../.github/scripts/changed-sections.js"; import { SECTION_KEYS, type SectionKey, UNDECLARED_POLICY_SECTIONS } from "../../src/schema.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; -const REPO_ROOT = join(import.meta.dir, "..", ".."); -const SRC_DIR = join(REPO_ROOT, "src"); +const SRC_DIR = join(ROOT, "src"); const SECTIONS_DIR = join(SRC_DIR, "sections"); function changed(...paths: string[]): ChangedFile[] { @@ -46,17 +46,8 @@ function inKeyOrder(...keys: SectionKey[]): SectionKey[] { return SECTION_KEYS.filter((key) => wanted.has(key)); } -const scratchRoots: string[] = []; -afterAll(() => { - for (const root of scratchRoots) { - rmSync(root, { recursive: true, force: true }); - } -}); - -/** A throwaway repo root under os.tmpdir() holding `files` (repo-relative path -> text). */ -function syntheticRepo(files: Record): string { - const root = mkdtempSync(join(tmpdir(), "changed-sections-")); - scratchRoots.push(root); +/** `root` filled as a repo holding `files` (repo-relative path -> text). */ +function syntheticRepo(root: string, files: Record): string { mkdirSync(join(root, "src", "sections", "shared"), { recursive: true }); for (const [path, text] of Object.entries(files)) { mkdirSync(dirname(join(root, path)), { recursive: true }); @@ -87,7 +78,7 @@ describe("changed-sections derived fan-out", () => { test("the real tree derives exactly the layout's fan-out for every shared file", () => { // A canary over the whole map: a new or dropped importer, a new or deleted shared file, or a hub edge reopening surfaces here and forces a // decision. - expect(deriveSharedFanOut(REPO_ROOT)).toEqual({ + expect(deriveSharedFanOut(ROOT)).toEqual({ "roles.ts": inKeyOrder("collaborators", "teams"), "secrets-engine.ts": inKeyOrder( "environments", @@ -208,93 +199,99 @@ describe("changed-sections derived fan-out", () => { ).toEqual(["./cli/program.js"]); }); - test("resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", () => { - const root = syntheticRepo({ - "src/sections/shared/engine.ts": "", - "src/sections/shared/util/index.ts": "", - "src/sections/labels/index.ts": "", - "lib/settings.schema.json": "{}", - }); - const importer = join(root, "src/sections/labels/index.ts"); - expect(resolveImport(importer, "../shared/engine.js")).toBe( - join(root, "src/sections/shared/engine.ts"), - ); - expect(resolveImport(importer, "../shared/engine")).toBe( - join(root, "src/sections/shared/engine.ts"), - ); - expect(resolveImport(importer, "../shared/util")).toBe( - join(root, "src/sections/shared/util/index.ts"), - ); - expect(resolveImport(importer, "../../../lib/settings.schema.json")).toBe( - join(root, "lib/settings.schema.json"), - ); - expect(() => resolveImport(importer, "../shared/missing.js")).toThrow( - /imports "\.\.\/shared\/missing\.js", which resolves to no file/, - ); - expect(() => resolveImport(importer, "../../../lib/missing.json")).toThrow( - /imports "\.\.\/\.\.\/\.\.\/lib\/missing\.json", which resolves to no file/, - ); - }); - - test("the fan-out follows the graph through intermediates and ignores non-section importers", () => { - const fanOut = deriveSharedFanOut(syntheticRepo(GRAPH_FIXTURE)); - expect(fanOut).toEqual({ - // src/schema.ts imports engine directly and registry.ts reaches it through teams; neither adds a key, and pages' type-only import is no edge. - "engine.ts": inKeyOrder("labels", "teams"), - "factory.ts": inKeyOrder("labels"), - // labels' unit test imports util too and is not an edge. - "util/index.ts": inKeyOrder("pages", "milestones"), - }); - }); - - test("a new import edge widens exactly the shared file it reaches", () => { - const fanOut = deriveSharedFanOut( - syntheticRepo({ - ...GRAPH_FIXTURE, - "src/sections/webhooks/index.ts": - 'import { engine } from "../shared/engine.js";\nexport default engine;\n', - }), - ); - expect(fanOut).toEqual({ - "engine.ts": inKeyOrder("labels", "teams", "webhooks"), - "factory.ts": inKeyOrder("labels"), - "util/index.ts": inKeyOrder("pages", "milestones"), - }); - }); - - test("a shared file no section imports throws", () => { - const root = syntheticRepo({ - "src/sections/shared/live.ts": "export const live = 1;\n", - "src/sections/shared/dead.ts": "export const dead = 1;\n", - "src/sections/labels/index.ts": - 'import { live } from "../shared/live.js";\nexport default live;\n', - }); - expect(() => deriveSharedFanOut(root)).toThrow( - /no section imports src\/sections\/shared\/dead\.ts/, - ); - }); - - test("a dangling relative import anywhere under src throws", () => { - const root = syntheticRepo({ - "src/sections/shared/engine.ts": "export const engine = 1;\n", - "src/sections/labels/index.ts": - 'import { gone } from "../shared/gone.js";\nexport default gone;\n', - }); - expect(() => deriveSharedFanOut(root)).toThrow(/resolves to no file/); - }); - - test("a computed import anywhere under src fails the whole derivation, naming the file", () => { - // The template edge in GRAPH_FIXTURE (pages/mock.ts) proves a substitution-free template passes. - for (const load of ["await import(which)", "require(which)", `await import(\`\${which}\`)`]) { - const root = syntheticRepo({ - ...GRAPH_FIXTURE, - "src/sections/webhooks/index.ts": `const which = "../shared/engine.js";\nexport const engine = ${load};\n`, + test("resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", () => + withTempDir("changed-sections-", (dir) => { + const root = syntheticRepo(dir, { + "src/sections/shared/engine.ts": "", + "src/sections/shared/util/index.ts": "", + "src/sections/labels/index.ts": "", + "lib/settings.schema.json": "{}", }); - expect(() => deriveSharedFanOut(root), load).toThrow( - /src\/sections\/webhooks\/index\.ts:2 loads a module through a computed specifier/, + const importer = join(root, "src/sections/labels/index.ts"); + expect(resolveImport(importer, "../shared/engine.js")).toBe( + join(root, "src/sections/shared/engine.ts"), ); - } - }); + expect(resolveImport(importer, "../shared/engine")).toBe( + join(root, "src/sections/shared/engine.ts"), + ); + expect(resolveImport(importer, "../shared/util")).toBe( + join(root, "src/sections/shared/util/index.ts"), + ); + expect(resolveImport(importer, "../../../lib/settings.schema.json")).toBe( + join(root, "lib/settings.schema.json"), + ); + expect(() => resolveImport(importer, "../shared/missing.js")).toThrow( + /imports "\.\.\/shared\/missing\.js", which resolves to no file/, + ); + expect(() => resolveImport(importer, "../../../lib/missing.json")).toThrow( + /imports "\.\.\/\.\.\/\.\.\/lib\/missing\.json", which resolves to no file/, + ); + })); + + test("the fan-out follows the graph through intermediates and ignores non-section importers", () => + withTempDir("changed-sections-", (dir) => { + const fanOut = deriveSharedFanOut(syntheticRepo(dir, GRAPH_FIXTURE)); + expect(fanOut).toEqual({ + // src/schema.ts imports engine directly and registry.ts reaches it through teams; neither adds a key, and pages' type-only import is no edge. + "engine.ts": inKeyOrder("labels", "teams"), + "factory.ts": inKeyOrder("labels"), + // labels' unit test imports util too and is not an edge. + "util/index.ts": inKeyOrder("pages", "milestones"), + }); + })); + + test("a new import edge widens exactly the shared file it reaches", () => + withTempDir("changed-sections-", (dir) => { + const fanOut = deriveSharedFanOut( + syntheticRepo(dir, { + ...GRAPH_FIXTURE, + "src/sections/webhooks/index.ts": + 'import { engine } from "../shared/engine.js";\nexport default engine;\n', + }), + ); + expect(fanOut).toEqual({ + "engine.ts": inKeyOrder("labels", "teams", "webhooks"), + "factory.ts": inKeyOrder("labels"), + "util/index.ts": inKeyOrder("pages", "milestones"), + }); + })); + + test("a shared file no section imports throws", () => + withTempDir("changed-sections-", (dir) => { + const root = syntheticRepo(dir, { + "src/sections/shared/live.ts": "export const live = 1;\n", + "src/sections/shared/dead.ts": "export const dead = 1;\n", + "src/sections/labels/index.ts": + 'import { live } from "../shared/live.js";\nexport default live;\n', + }); + expect(() => deriveSharedFanOut(root)).toThrow( + /no section imports src\/sections\/shared\/dead\.ts/, + ); + })); + + test("a dangling relative import anywhere under src throws", () => + withTempDir("changed-sections-", (dir) => { + const root = syntheticRepo(dir, { + "src/sections/shared/engine.ts": "export const engine = 1;\n", + "src/sections/labels/index.ts": + 'import { gone } from "../shared/gone.js";\nexport default gone;\n', + }); + expect(() => deriveSharedFanOut(root)).toThrow(/resolves to no file/); + })); + + test("a computed import anywhere under src fails the whole derivation, naming the file", () => + withTempDir("changed-sections-", (dir) => { + // The template edge in GRAPH_FIXTURE (pages/mock.ts) proves a substitution-free template passes. + for (const load of ["await import(which)", "require(which)", `await import(\`\${which}\`)`]) { + const root = syntheticRepo(dir, { + ...GRAPH_FIXTURE, + "src/sections/webhooks/index.ts": `const which = "../shared/engine.js";\nexport const engine = ${load};\n`, + }); + expect(() => deriveSharedFanOut(root), load).toThrow( + /src\/sections\/webhooks\/index\.ts:2 loads a module through a computed specifier/, + ); + } + })); }); describe("changed-sections file map", () => { @@ -381,26 +378,29 @@ describe("changed-sections selection", () => { ); }); - test("a deleted shared file whose importers now resolve to its sibling spelling selects that sibling's sections", () => { - // foo.ts and foo/index.ts are interchangeable to an importer of "./foo.js", so deleting one leaves the importers unchanged and typecheck green. - const fanOut = deriveSharedFanOut( - syntheticRepo({ - "src/sections/shared/a/index.ts": "export const a = 1;\n", - "src/sections/shared/b.ts": "export const b = 1;\n", - "src/sections/labels/index.ts": 'import { a } from "../shared/a.js";\nexport default a;\n', - "src/sections/teams/index.ts": 'import { b } from "../shared/b.js";\nexport default b;\n', - }), - ); - const select = (files: ChangedFile[]) => renderSelection(sectionsForFiles(files, () => fanOut)); - expect(select(removed("src/sections/shared/a.ts"))).toBe("labels"); - expect(select(removed("src/sections/shared/b/index.ts"))).toBe("teams"); - expect(select(removed("src/sections/shared/c.ts"))).toBe("none"); - // Only .ts files are selector inputs, so "a.js" cannot borrow a/index.ts. - expect(() => select(removed("src/sections/shared/a.js"))).toThrow(/matches no selector rule/); - expect(() => select(removed("src/sections/shared/notes.md"))).toThrow( - /matches no selector rule/, - ); - }); + test("a deleted shared file whose importers now resolve to its sibling spelling selects that sibling's sections", () => + withTempDir("changed-sections-", (dir) => { + // foo.ts and foo/index.ts are interchangeable to an importer of "./foo.js", so deleting one leaves the importers unchanged and typecheck green. + const fanOut = deriveSharedFanOut( + syntheticRepo(dir, { + "src/sections/shared/a/index.ts": "export const a = 1;\n", + "src/sections/shared/b.ts": "export const b = 1;\n", + "src/sections/labels/index.ts": + 'import { a } from "../shared/a.js";\nexport default a;\n', + "src/sections/teams/index.ts": 'import { b } from "../shared/b.js";\nexport default b;\n', + }), + ); + const select = (files: ChangedFile[]) => + renderSelection(sectionsForFiles(files, () => fanOut)); + expect(select(removed("src/sections/shared/a.ts"))).toBe("labels"); + expect(select(removed("src/sections/shared/b/index.ts"))).toBe("teams"); + expect(select(removed("src/sections/shared/c.ts"))).toBe("none"); + // Only .ts files are selector inputs, so "a.js" cannot borrow a/index.ts. + expect(() => select(removed("src/sections/shared/a.js"))).toThrow(/matches no selector rule/); + expect(() => select(removed("src/sections/shared/notes.md"))).toThrow( + /matches no selector rule/, + ); + })); test("parseNameStatus reads NUL-delimited records raw and throws on any other shape", () => { // -z keeps a path with a tab, a quote, and a backslash verbatim; git would C-quote it otherwise and the src/sections/ prefix would go unmatched. diff --git a/test/scripts/check-compat-markers.test.ts b/test/scripts/check-compat-markers.test.ts index 24e6c852..5fd0031c 100644 --- a/test/scripts/check-compat-markers.test.ts +++ b/test/scripts/check-compat-markers.test.ts @@ -1,24 +1,16 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { checkCompatMarkers } from "../../.github/scripts/check-compat-markers.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; -const SCRIPT = join(import.meta.dir, "..", "..", ".github", "scripts", "check-compat-markers.ts"); +const SCRIPT = join(ROOT, ".github", "scripts", "check-compat-markers.ts"); -const roots: string[] = []; -afterAll(() => { - for (const root of roots) { - rmSync(root, { recursive: true, force: true }); - } -}); - -/** A throwaway git repository under os.tmpdir() holding `files`, its package.json at `version`. Its own .gitignore - * is the only excludes source: the machine's global excludes file would otherwise hide fixture files. */ -function repo(version: string, files: Record): string { - const root = mkdtempSync(join(tmpdir(), "compat-markers-")); - roots.push(root); +/** `root` filled as a git repository holding `files`, its package.json at `version`. Its own .gitignore is the only + * excludes source: the machine's global excludes file would otherwise hide fixture files. */ +function repo(root: string, version: string, files: Record): string { execFileSync("git", ["init", "--quiet"], { cwd: root }); execFileSync("git", ["config", "core.excludesFile", "/dev/null"], { cwd: root }); writeFileSync(join(root, "package.json"), `${JSON.stringify({ name: "fixture", version })}\n`); @@ -40,94 +32,96 @@ function due(where: string, major: number, dueLine: string): string { } describe("checkCompatMarkers", () => { - test("lists well-formed markers by removal major across file types and passes under the due line", () => { - const cwd = repo("2.0.0", { - "src/inputs.ts": - 'const dir = input("repos_dir"); // COMPAT(v3): accept the pre-2.1 "repos_dir" input name; delete this branch and the alias\n', - "src/cache.ts": - "/* COMPAT(v4): read the v3 cache layout; delete the reader */ // COMPAT(v4): and the v3 key format; delete the parser\nexport {};\n", - "src/config.ts": - '// COMPAT(v3): config.json\'s retired "legacy" key still loads; delete this fallback\n', - "config.json": '{"legacy": true}\n', - ".github/workflows/publish.yml": - "name: publish\n# COMPAT(v3): tolerate the old asset name; delete this step\n", - "docs/anchors.md": - "The marker is `COMPAT(vN): `, a `COMPAT(vN)` comment.\n\n" + - "COMPAT(v5): keep the old anchor ids; delete the redirect table\n" + - "\n" + - "Use `COMPAT(vN): reason; for example COMPAT(v5): keep the old path; delete it`.\n", - }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " .github/workflows/publish.yml:2 tolerate the old asset name; delete this step", - ' src/config.ts:1 config.json\'s retired "legacy" key still loads; delete this fallback', - ' src/inputs.ts:1 accept the pre-2.1 "repos_dir" input name; delete this branch and the alias', - " v4", - " src/cache.ts:1 read the v3 cache layout; delete the reader", - " src/cache.ts:1 and the v3 key format; delete the parser", - " v5", - " docs/anchors.md:3 keep the old anchor ids; delete the redirect table", - " docs/anchors.md:4 the old heading slugs still resolve; delete the redirect list", - " docs/anchors.md:5 keep the old path; delete it`.", - "", - ].join("\n"), - stderr: "", - }); - }); + test("lists well-formed markers by removal major across file types and passes under the due line", () => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, "2.0.0", { + "src/inputs.ts": + 'const dir = input("repos_dir"); // COMPAT(v3): accept the pre-2.1 "repos_dir" input name; delete this branch and the alias\n', + "src/cache.ts": + "/* COMPAT(v4): read the v3 cache layout; delete the reader */ // COMPAT(v4): and the v3 key format; delete the parser\nexport {};\n", + "src/config.ts": + '// COMPAT(v3): config.json\'s retired "legacy" key still loads; delete this fallback\n', + "config.json": '{"legacy": true}\n', + ".github/workflows/publish.yml": + "name: publish\n# COMPAT(v3): tolerate the old asset name; delete this step\n", + "docs/anchors.md": + "The marker is `COMPAT(vN): `, a `COMPAT(vN)` comment.\n\n" + + "COMPAT(v5): keep the old anchor ids; delete the redirect table\n" + + "\n" + + "Use `COMPAT(vN): reason; for example COMPAT(v5): keep the old path; delete it`.\n", + }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " .github/workflows/publish.yml:2 tolerate the old asset name; delete this step", + ' src/config.ts:1 config.json\'s retired "legacy" key still loads; delete this fallback', + ' src/inputs.ts:1 accept the pre-2.1 "repos_dir" input name; delete this branch and the alias', + " v4", + " src/cache.ts:1 read the v3 cache layout; delete the reader", + " src/cache.ts:1 and the v3 key format; delete the parser", + " v5", + " docs/anchors.md:3 keep the old anchor ids; delete the redirect table", + " docs/anchors.md:4 the old heading slugs still resolve; delete the redirect list", + " docs/anchors.md:5 keep the old path; delete it`.", + "", + ].join("\n"), + stderr: "", + }); + })); - test("names every malformed occurrence by file and line, and still lists the well-formed ones", () => { - const cwd = repo("2.0.0", { - "src/m.ts": [ - "// COMPAT(v3) accept the old key", - "// COMPAT(3): drop the v prefix", - "// COMPAT(v3):", - "// COMPAT(vN): the placeholder outside Markdown", - "// COMPAT(v03): a leading zero", - "/* COMPAT(v3): */", - "const legacy = /* COMPAT(v3): */ true;", - "/* See #123. COMPAT(v3): */", - "/* issue #5: COMPAT(v3): real text; delete it */", - "// COMPAT(v3): fine; delete it", - '// COMPAT(v3): the "#" alias still parses; delete support for #', - "// COMPAT(v3): keep the src/**/ glob; delete the legacy matcher */", - "/* closed */ // COMPAT(v3): a closed block before the marker; delete the matcher */", - "", - ].join("\n"), - "notes.md": - "A `COMPAT(vN)` marker, and one gone wrong: COMPAT(v3)\n\n\n`COMPAT(vN): keep the old path; delete it\n", - }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 1, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " src/m.ts:9 real text; delete it", - " src/m.ts:10 fine; delete it", - ' src/m.ts:11 the "#" alias still parses; delete support for #', - " src/m.ts:12 keep the src/**/ glob; delete the legacy matcher */", - " src/m.ts:13 a closed block before the marker; delete the matcher */", - "", - ].join("\n"), - stderr: [ - malformed("notes.md:1", "COMPAT(v3)"), - malformed("notes.md:2", "COMPAT(v3): -->"), - malformed("notes.md:3", "COMPAT(vN): keep the old anchor; delete the redirect -->"), - malformed("notes.md:4", "COMPAT(vN): keep the old path; delete it"), - malformed("src/m.ts:1", "COMPAT(v3) accept the old key"), - malformed("src/m.ts:2", "COMPAT(3): drop the v prefix"), - malformed("src/m.ts:3", "COMPAT(v3):"), - malformed("src/m.ts:4", "COMPAT(vN): the placeholder outside Markdown"), - malformed("src/m.ts:5", "COMPAT(v03): a leading zero"), - malformed("src/m.ts:6", "COMPAT(v3): */"), - malformed("src/m.ts:7", "COMPAT(v3): */ true;"), - malformed("src/m.ts:8", "COMPAT(v3): */"), - "", - ].join("\n"), - }); - }); + test("names every malformed occurrence by file and line, and still lists the well-formed ones", () => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, "2.0.0", { + "src/m.ts": [ + "// COMPAT(v3) accept the old key", + "// COMPAT(3): drop the v prefix", + "// COMPAT(v3):", + "// COMPAT(vN): the placeholder outside Markdown", + "// COMPAT(v03): a leading zero", + "/* COMPAT(v3): */", + "const legacy = /* COMPAT(v3): */ true;", + "/* See #123. COMPAT(v3): */", + "/* issue #5: COMPAT(v3): real text; delete it */", + "// COMPAT(v3): fine; delete it", + '// COMPAT(v3): the "#" alias still parses; delete support for #', + "// COMPAT(v3): keep the src/**/ glob; delete the legacy matcher */", + "/* closed */ // COMPAT(v3): a closed block before the marker; delete the matcher */", + "", + ].join("\n"), + "notes.md": + "A `COMPAT(vN)` marker, and one gone wrong: COMPAT(v3)\n\n\n`COMPAT(vN): keep the old path; delete it\n", + }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 1, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " src/m.ts:9 real text; delete it", + " src/m.ts:10 fine; delete it", + ' src/m.ts:11 the "#" alias still parses; delete support for #', + " src/m.ts:12 keep the src/**/ glob; delete the legacy matcher */", + " src/m.ts:13 a closed block before the marker; delete the matcher */", + "", + ].join("\n"), + stderr: [ + malformed("notes.md:1", "COMPAT(v3)"), + malformed("notes.md:2", "COMPAT(v3): -->"), + malformed("notes.md:3", "COMPAT(vN): keep the old anchor; delete the redirect -->"), + malformed("notes.md:4", "COMPAT(vN): keep the old path; delete it"), + malformed("src/m.ts:1", "COMPAT(v3) accept the old key"), + malformed("src/m.ts:2", "COMPAT(3): drop the v prefix"), + malformed("src/m.ts:3", "COMPAT(v3):"), + malformed("src/m.ts:4", "COMPAT(vN): the placeholder outside Markdown"), + malformed("src/m.ts:5", "COMPAT(v03): a leading zero"), + malformed("src/m.ts:6", "COMPAT(v3): */"), + malformed("src/m.ts:7", "COMPAT(v3): */ true;"), + malformed("src/m.ts:8", "COMPAT(v3): */"), + "", + ].join("\n"), + }); + })); // The due line is package.json's major, or the major a release PR cuts; a marker at or below it fails. test.each<[string, string, number, number | undefined, string | undefined]>([ @@ -146,68 +140,73 @@ describe("checkCompatMarkers", () => { ["a release targeting a major above the marker's", "2.0.0", 2, 3, "the release target v3"], ["a marker beyond the release target passes", "2.0.0", 4, 3, undefined], ["a minor release targets the current major, the default gate", "2.0.0", 3, 2, undefined], - ])("%s", (_, version, major, targetMajor, dueLine) => { - const cwd = repo(version, { - "src/x.ts": `// COMPAT(v${major}): the old shape still parses; delete the branch\n`, - }); - const context = - targetMajor === undefined - ? `package.json ${version}` - : `package.json ${version}, release target v${targetMajor}`; - expect(checkCompatMarkers({ cwd, targetMajor })).toEqual({ - code: dueLine === undefined ? 0 : 1, - stdout: [ - `COMPAT markers by removal major (${context}):`, - ` v${major}`, - " src/x.ts:1 the old shape still parses; delete the branch", - "", - ].join("\n"), - stderr: dueLine === undefined ? "" : `${due("src/x.ts:1", major, dueLine)}\n`, - }); - }); + ])("%s", (_, version, major, targetMajor, dueLine) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, { + "src/x.ts": `// COMPAT(v${major}): the old shape still parses; delete the branch\n`, + }); + const context = + targetMajor === undefined + ? `package.json ${version}` + : `package.json ${version}, release target v${targetMajor}`; + expect(checkCompatMarkers({ cwd, targetMajor })).toEqual({ + code: dueLine === undefined ? 0 : 1, + stdout: [ + `COMPAT markers by removal major (${context}):`, + ` v${major}`, + " src/x.ts:1 the old shape still parses; delete the branch", + "", + ].join("\n"), + stderr: dueLine === undefined ? "" : `${due("src/x.ts:1", major, dueLine)}\n`, + }); + }), + ); - test("skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", () => { - const marker = "// COMPAT(v3): kept; delete it\n"; - const cwd = repo("2.0.0", { - ".gitignore": "scratch/\n", - "lib/index.js": marker, - "node_modules/dep/index.js": marker, - "test/e2e/openapi/github-openapi.trimmed.json": marker, - "CHANGELOG.md": "* remove the COMPAT(v3) legacy reader (#12)\n", - "scratch/out.ts": marker, - "gone.ts": marker, - ".github/scripts/check-compat-markers.ts": "// COMPAT(v3) the syntax, spelled to define it\n", - "test/scripts/check-compat-markers.test.ts": "// COMPAT(3): a fixture text\n", - ".github/scripts/check-compat-markers.tsx": marker, - "AGENTS.md": - "Rules.\n\nCOMPAT(v3): the old rule wording still applies; delete this paragraph\n", - "src/real.ts": marker, - }); - symlinkSync("AGENTS.md", join(cwd, "CLAUDE.md")); - execFileSync("git", ["add", "gone.ts"], { cwd }); - rmSync(join(cwd, "gone.ts")); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " .github/scripts/check-compat-markers.tsx:1 kept; delete it", - " AGENTS.md:3 the old rule wording still applies; delete this paragraph", - " src/real.ts:1 kept; delete it", - "", - ].join("\n"), - stderr: "", - }); - }); + test("skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", () => + withTempDir("compat-markers-", (dir) => { + const marker = "// COMPAT(v3): kept; delete it\n"; + const cwd = repo(dir, "2.0.0", { + ".gitignore": "scratch/\n", + "lib/index.js": marker, + "node_modules/dep/index.js": marker, + "test/e2e/openapi/github-openapi.trimmed.json": marker, + "CHANGELOG.md": "* remove the COMPAT(v3) legacy reader (#12)\n", + "scratch/out.ts": marker, + "gone.ts": marker, + ".github/scripts/check-compat-markers.ts": + "// COMPAT(v3) the syntax, spelled to define it\n", + "test/scripts/check-compat-markers.test.ts": "// COMPAT(3): a fixture text\n", + ".github/scripts/check-compat-markers.tsx": marker, + "AGENTS.md": + "Rules.\n\nCOMPAT(v3): the old rule wording still applies; delete this paragraph\n", + "src/real.ts": marker, + }); + symlinkSync("AGENTS.md", join(cwd, "CLAUDE.md")); + execFileSync("git", ["add", "gone.ts"], { cwd }); + rmSync(join(cwd, "gone.ts")); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " .github/scripts/check-compat-markers.tsx:1 kept; delete it", + " AGENTS.md:3 the old rule wording still applies; delete this paragraph", + " src/real.ts:1 kept; delete it", + "", + ].join("\n"), + stderr: "", + }); + })); - test("a tree without markers says so", () => { - const cwd = repo("2.0.0", { "src/x.ts": "export {};\n" }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: "no COMPAT markers (package.json 2.0.0)\n", - stderr: "", - }); - }); + test("a tree without markers says so", () => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, "2.0.0", { "src/x.ts": "export {};\n" }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: "no COMPAT markers (package.json 2.0.0)\n", + stderr: "", + }); + })); test.each<[string, string, number | undefined, string]>([ [ @@ -222,19 +221,22 @@ describe("checkCompatMarkers", () => { undefined, 'package.json\'s version "2.0" is not X.Y.Z; refusing to derive the due line from it.', ], - ])("refuses %s", (_, version, targetMajor, message) => { - const cwd = repo(version, {}); - expect(() => checkCompatMarkers({ cwd, targetMajor })).toThrow(message); - }); + ])("refuses %s", (_, version, targetMajor, message) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, {}); + expect(() => checkCompatMarkers({ cwd, targetMajor })).toThrow(message); + }), + ); - test("a listed path that cannot be inspected for any reason but absence stops the scan", () => { - const cwd = repo("2.0.0", { "dir/file.ts": "export {};\n" }); - execFileSync("git", ["add", "dir/file.ts"], { cwd }); - // The tracked path's parent is now a regular file: lstat fails with ENOTDIR, not ENOENT. - rmSync(join(cwd, "dir"), { recursive: true }); - writeFileSync(join(cwd, "dir"), ""); - expect(() => checkCompatMarkers({ cwd })).toThrow(/ENOTDIR/); - }); + test("a listed path that cannot be inspected for any reason but absence stops the scan", () => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, "2.0.0", { "dir/file.ts": "export {};\n" }); + execFileSync("git", ["add", "dir/file.ts"], { cwd }); + // The tracked path's parent is now a regular file: lstat fails with ENOTDIR, not ENOENT. + rmSync(join(cwd, "dir"), { recursive: true }); + writeFileSync(join(cwd, "dir"), ""); + expect(() => checkCompatMarkers({ cwd })).toThrow(/ENOTDIR/); + })); }); describe("the CLI", () => { @@ -286,9 +288,11 @@ describe("the CLI", () => { "check-compat-markers: --target-major 1 is below package.json's major 2 (2.0.0); a release never targets an older major.\n", }, ], - ])("%s", (_, version, argv, expected) => { - const cwd = repo(version, { "src/x.ts": DUE }); - const run = spawnSync("bun", [SCRIPT, ...argv], { cwd, encoding: "utf8" }); - expect({ status: run.status, stdout: run.stdout, stderr: run.stderr }).toEqual(expected); - }); + ])("%s", (_, version, argv, expected) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, { "src/x.ts": DUE }); + const run = spawnSync("bun", [SCRIPT, ...argv], { cwd, encoding: "utf8" }); + expect({ status: run.status, stdout: run.stdout, stderr: run.stderr }).toEqual(expected); + }), + ); }); diff --git a/test/scripts/gen-action-docs.test.ts b/test/scripts/gen-action-docs.test.ts index 95c99671..3a1b434e 100644 --- a/test/scripts/gen-action-docs.test.ts +++ b/test/scripts/gen-action-docs.test.ts @@ -23,10 +23,9 @@ import { OUTPUT_DECLS } from "../../src/action/io.js"; import { INPUT_DECLS } from "../../src/flows/inputs.js"; import type { SectionMeta } from "../../src/sections/contract/module.js"; import { sectionModule } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; import { relocatedRegion } from "./relocated-region.js"; -const ROOT = join(import.meta.dir, "..", ".."); - describe("action.yml renderers", () => { test("inputs fold long descriptions, quote every default, and parse back verbatim", () => { const decls = { diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index c58d3a64..ae3651ec 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -14,10 +14,9 @@ import { renderSectionsTable, } from "../../.github/scripts/gen-docs.js"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; +import { ROOT } from "../root.js"; import { relocatedRegion } from "./relocated-region.js"; -const ROOT = join(import.meta.dir, "..", ".."); - describe("renderSectionsTable", () => { test("renders one row per section, derived cells around the authored ones", () => { const table = renderSectionsTable( @@ -345,16 +344,9 @@ describe("patFormParameters and renderPatFormUrl", () => { describe("the committed pages", () => { const pages = Object.keys(PAGE_REGIONS); - test("are exactly what the generator renders, and every page is registered", () => { + test("are exactly what the generator renders", () => { // Every generated region fresh (build:check's contract) also proves the Sections renderer parses every real grant prose and every real permission - // has a form parameter. - expect(pages.sort()).toEqual([ - "README.md", - "docs/reference/architecture.md", - "docs/reference/inputs.md", - "docs/reference/sections.md", - "docs/start/getting-started.md", - ]); + // has a form parameter. The page set is pinned against the tree by test/scripts/generated.test.ts, through the generated-output table. for (const path of pages) { const text = readFileSync(join(ROOT, path), "utf8"); expect(renderPage(path, text), path).toBe(text); diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts new file mode 100644 index 00000000..25fc3745 --- /dev/null +++ b/test/scripts/generated.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { cpSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; +import { + hasGeneratedRegion, + markerSyntaxFor, +} from "../../.github/scripts/lib/generated-regions.js"; +import { ROOT } from "../root.js"; +import { withTempDir } from "../temp-dir.js"; + +/** The file types generated-regions.ts has a marker syntax for; a marker string anywhere else is test or script text. */ +const REGION_FILE = /\.(?:md|ya?ml)$/; +/** The outputs the marker scan cannot see: whole generated files. */ +const WHOLE_FILES = ["lib/settings.schema.json", "src/upstream-gaps/index.ts"]; + +const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8" }) + .split("\0") + .filter((path) => path !== ""); + +describe("the generated-output table", () => { + test("is exactly the tree's generated files: every tracked marker-carrying page plus the whole files, once each", () => { + const carrying = tracked.filter( + (path) => + REGION_FILE.test(path) && + hasGeneratedRegion(readFileSync(join(ROOT, path), "utf8"), markerSyntaxFor(path)), + ); + const paths = generatedPaths(); + expect([...paths].sort()).toEqual([...carrying, ...WHOLE_FILES].sort()); + expect(new Set(paths).size).toBe(paths.length); + expect( + GENERATED_OUTPUTS.filter((output) => output.kind === "file") + .map((output) => output.path) + .sort(), + ).toEqual(WHOLE_FILES); + for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { + expect(tracked, generator).toContain(generator); + } + }); + + test("the scan reads the shared marker grammar: an inline HTML marker counts, a marker-shaped scalar does not", () => { + // A page whose only marker sits mid-line (inputs.md's outputs-list is the tree's inline case) would slip a line-anchored scan. + expect( + hasGeneratedRegion( + "- `result`: x", + "html", + ), + ).toBe(true); + expect(hasGeneratedRegion("the words BEGIN GENERATED: a outside a comment\n", "html")).toBe( + false, + ); + expect( + hasGeneratedRegion("inputs:\n # BEGIN GENERATED: a\n # END GENERATED: a\n", "yaml"), + ).toBe(true); + expect(hasGeneratedRegion('d: "one\n # BEGIN GENERATED: a\n two"\n', "yaml")).toBe(false); + }); +}); + +describe("the build:check runner", () => { + function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); + } + + /** The runner's exit code and everything it printed on either stream. */ + function runner(cwd: string): { status: number; printed: string } { + const run = Bun.spawnSync([process.execPath, ".github/scripts/generated.ts"], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + return { status: run.exitCode, printed: run.stdout.toString() + run.stderr.toString() }; + } + + test( + "a clean clone passes; a staged stale byte in a region file and in a whole file fails naming both", + () => + withTempDir("build-check-", (dir) => { + // HEAD's tree with its own index, sharing the object store and node_modules; the runner and the + // generators are copied from the working tree, so the code under test is the code being edited. + git(ROOT, "clone", "--quiet", "--shared", ROOT, dir); + symlinkSync(join(ROOT, "node_modules"), join(dir, "node_modules")); + cpSync(join(ROOT, ".github", "scripts"), join(dir, ".github", "scripts"), { + recursive: true, + }); + const untouched = [".", ":(exclude)node_modules", ":(exclude).github/scripts"]; + + const clean = runner(dir); + expect(clean.status).toBe(0); + expect(clean.printed).not.toContain("drifted"); + expect(git(dir, "status", "--porcelain", "--", ...untouched)).toBe(""); + + // A stale cell the generator repairs (the row shape holds), and a stale byte in a wholesale file. + const table = join(dir, "docs/reference/sections.md"); + const page = readFileSync(table, "utf8"); + expect(page).toContain("| `labels` |"); + writeFileSync(table, page.replace("| `labels` |", "| `labelz` |")); + const index = join(dir, "src/upstream-gaps/index.ts"); + writeFileSync(index, `${readFileSync(index, "utf8")}\n`); + git(dir, "add", "docs/reference/sections.md", "src/upstream-gaps/index.ts"); + + const stale = runner(dir); + expect(stale.status).toBe(1); + // git's own --stat lists the two staged stale paths. + expect(stale.printed).toContain("docs/reference/sections.md"); + expect(stale.printed).toContain("src/upstream-gaps/index.ts"); + // The generators repaired the working tree; only the staged stale copies differ. + expect( + git(dir, "diff", "--name-only", "--", ...untouched) + .trim() + .split("\n") + .sort(), + ).toEqual(["docs/reference/sections.md", "src/upstream-gaps/index.ts"]); + }), + 120_000, + ); +}); diff --git a/test/scripts/graduate-upstream-gaps.test.ts b/test/scripts/graduate-upstream-gaps.test.ts index 01d2cbbd..24b31710 100644 --- a/test/scripts/graduate-upstream-gaps.test.ts +++ b/test/scripts/graduate-upstream-gaps.test.ts @@ -14,6 +14,7 @@ import { planGraduation, toSpecOnlyGapSource, } from "../../.github/scripts/graduate-upstream-gaps.js"; +import { ROOT } from "../root.js"; const TRIPWIRE_MESSAGE = "Type '\"GET /repos/{owner}/{repo}/merge-queue\"' does not satisfy the constraint 'never'."; @@ -383,7 +384,7 @@ describe("toSpecOnlyGapSource", () => { }); describe("the real src/upstream-gaps/ satisfies the scripts' contracts", () => { - const GAPS_DIR = join(import.meta.dir, "..", "..", "src", "upstream-gaps"); + const GAPS_DIR = join(ROOT, "src", "upstream-gaps"); const realIndex = readFileSync(join(GAPS_DIR, "index.ts"), "utf8"); const realGapFiles = readdirSync(GAPS_DIR) .filter((f) => f.endsWith(".ts")) @@ -396,7 +397,7 @@ describe("the real src/upstream-gaps/ satisfies the scripts' contracts", () => { test("spec-pinned detection agrees with each gap's actual kind and flag", async () => { for (const gap of realGapFiles) { - const abs = join(import.meta.dir, "..", "..", gap); + const abs = join(ROOT, gap); const { GAP } = (await import(abs)) as { GAP: { kind: "octokit"; documentedInSpec: boolean } | { kind: "spec-only" }; }; @@ -409,7 +410,7 @@ describe("the real src/upstream-gaps/ satisfies the scripts' contracts", () => { // The sweep is legitimately empty once every octokit-kind gap has graduated, so the corpus is not pinned here; the synthetic fixtures pin the // transform. for (const gap of realGapFiles) { - const abs = join(import.meta.dir, "..", "..", gap); + const abs = join(ROOT, gap); const source = readFileSync(abs, "utf8"); if (!isSpecPinned(source)) { continue; diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index d90477a1..bf99dfe4 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -28,6 +28,7 @@ import { stablePublishVerdict, verifyPublishedRefs, } from "../../.github/scripts/release-pipeline.js"; +import { ROOT } from "../root.js"; import { ANCHOR_PUSH, appendedSha, @@ -1340,9 +1341,7 @@ describe("anchorCheck", () => { describe("release configuration contract", () => { test("the committed config pins the tagless-draft knobs (shape only; the flow itself is not exercised here)", () => { - const config = JSON.parse( - readFileSync(join(import.meta.dir, "../../release-please-config.json"), "utf8"), - ) as { + const config = JSON.parse(readFileSync(join(ROOT, "release-please-config.json"), "utf8")) as { "skip-github-release"?: unknown; "include-component-in-tag"?: unknown; "last-release-sha"?: unknown; @@ -1496,7 +1495,7 @@ describe("prereleaseVersion", () => { env: Record, ...args: string[] ): Promise<{ stdout: string; stderr: string; status: number }> { - const script = join(import.meta.dir, "..", "..", ".github", "scripts", "release-pipeline.ts"); + const script = join(ROOT, ".github", "scripts", "release-pipeline.ts"); const child = Bun.spawn([process.execPath, script, ...args], { cwd, env: Object.fromEntries( diff --git a/test/sections/docs-registry.test.ts b/test/sections/docs-registry.test.ts index 8b973ec8..6f1e4664 100644 --- a/test/sections/docs-registry.test.ts +++ b/test/sections/docs-registry.test.ts @@ -16,8 +16,7 @@ import { endpointPath, type Route } from "../../src/sections/contract/endpoints. import { DOCS } from "../../src/sections/docs-registry.js"; import { allEndpoints, allGraphqlOps, SECTIONS } from "../../src/sections/registry.js"; import { CLAIM_FAMILY, CLAIM_STEMS, defaultClaimProblems, stemNegation } from "../docs/claims.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** `text` as a regex source matching itself literally. */ function escapeRe(text: string): string { diff --git a/test/sections/graphql-queries.test.ts b/test/sections/graphql-queries.test.ts index 40aecfab..602f6953 100644 --- a/test/sections/graphql-queries.test.ts +++ b/test/sections/graphql-queries.test.ts @@ -14,8 +14,9 @@ import { GRAPHQL_STATUS_CHECK_TWINS, } from "../../src/sections/branches/graphql-rules.js"; import { allGraphqlOps } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; -const SCHEMA_PATH = join(import.meta.dir, "..", "e2e", "graphql", "schema.docs.graphql"); +const SCHEMA_PATH = join(ROOT, "test", "e2e", "graphql", "schema.docs.graphql"); const FETCH_COMMAND = "bun .github/scripts/fetch-graphql-schema.ts"; const schemaAvailable = existsSync(SCHEMA_PATH); diff --git a/test/temp-dir.ts b/test/temp-dir.ts new file mode 100644 index 00000000..d5488fd1 --- /dev/null +++ b/test/temp-dir.ts @@ -0,0 +1,20 @@ +/** + * A temp directory whose lifetime is the body it is handed to: created under os.tmpdir(), removed on every exit + * path, failure included. `test("...", () => withTempDir("prefix-", async (dir) => {...}))` is the one shape. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export async function withTempDir( + prefix: string, + body: (dir: string) => T | Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), prefix)); + try { + return await body(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}