From db58b216b41c44c7305dcda7b7fe6d587f1c64e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 16:15:29 +0000 Subject: [PATCH] test(scripts): read spawned children's verdicts as numbers, not prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7897. A pin test that spawns a child and regexes the child's human-readable output fails in two silent ways: SGR sequences land inside the matched text under GitHub Actions (green locally, red only in CI), and a count spelled `\d+` is satisfied by `0` (green everywhere, forever, including for the outcome it exists to refuse). The census over `scripts/__tests__/` is in the PR body. This converts the subset it found unsafe or non-discriminating: - `helpers/child-verdict.ts` — one reader: `stripAnsi`, `verdictCount`, `selfTestCases`. Pinned by `child-verdict.test.ts`, including the non-equivalence of the old and new spellings. - eight self-test pins — `\d+ cases pass` also asserted as a count > 0. - `upstream-port-parity-wiring` — `ported file(s) match` reconciled against the pin shipped in the same commit, so an empty pin can no longer read as parity. - `check-control-bytes` — GNU grep 3.11 writes `binary file matches` on STDERR and exits 0 with an empty stdout, so the old negative assertion read a stream that message never reaches and could not fail; both streams are read now, and a NUL-bearing fixture is the control that the negative half can go red. - `shadcn-sync-fetch-cache` — the child colours unconditionally; the list is reconciled against the manifest the printer reads, after ANSI stripping. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- scripts/__tests__/bash32-floor-wiring.test.ts | 13 ++- scripts/__tests__/check-control-bytes.test.ts | 74 +++++++++++-- .../check-doc-fence-languages.test.ts | 13 ++- .../check-governed-queue-guard.test.ts | 13 ++- scripts/__tests__/check-half-states.test.ts | 13 ++- .../check-pre-install-import-graph.test.ts | 15 ++- scripts/__tests__/child-verdict.test.ts | 103 ++++++++++++++++++ scripts/__tests__/entry-guard-wiring.test.ts | 13 ++- scripts/__tests__/helpers/child-verdict.ts | 70 ++++++++++++ .../js-comment-mask-jsx-6891.test.ts | 13 ++- .../__tests__/shadcn-sync-fetch-cache.test.ts | 47 +++++++- .../upstream-port-parity-wiring.test.ts | 21 +++- 12 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 scripts/__tests__/child-verdict.test.ts create mode 100644 scripts/__tests__/helpers/child-verdict.ts diff --git a/scripts/__tests__/bash32-floor-wiring.test.ts b/scripts/__tests__/bash32-floor-wiring.test.ts index 00abb259e4..d8e51c6c02 100644 --- a/scripts/__tests__/bash32-floor-wiring.test.ts +++ b/scripts/__tests__/bash32-floor-wiring.test.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GATE = 'scripts/check-bash32-floor.mjs'; @@ -97,7 +99,16 @@ describe('check-bash32-floor is wired, not merely present', () => { it('its self-test passes — the half that makes a green scan mean something', () => { const out = execFileSync('node', [GATE, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/check-bash32-floor self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/check-bash32-floor self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'check-bash32-floor'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); }); diff --git a/scripts/__tests__/check-control-bytes.test.ts b/scripts/__tests__/check-control-bytes.test.ts index e5cf1263c9..9624b91d65 100644 --- a/scripts/__tests__/check-control-bytes.test.ts +++ b/scripts/__tests__/check-control-bytes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -286,6 +286,38 @@ describe('repo state — the gate is green on this tree', () => { }); }); +/** + * A content search, read on the stream grep actually writes its refusal to. + * + * objectui#7897. Measured on GNU grep 3.11 (the build this repo's containers + * carry, and the one this file's own header already cites): when grep declines + * a binary file it writes `grep: : binary file matches` to **stderr**, + * prints nothing at all on stdout, and still exits **0**. `execFileSync` + * returns stdout ONLY — so the previous spelling here, + * `expect(out).not.toMatch(/binary file matches/)`, was matching against a + * stream that message can never reach. It could not fail, for any file, ever: + * a guard that reads as a pin while being satisfied by every outcome including + * the one it names. The whole pin was carried by the positive assertion beside + * it. Both halves are load-bearing now, and + * `the refusal this asserts against is a refusal grep really makes` below is + * the control that proves the negative half can go red. + * + * The two spellings grep has used for the refusal are both recognised: modern + * GNU grep prefixes `grep: : `, older builds print `Binary file + * matches` on stdout. Reading BOTH streams means this does not depend on which. + */ +function contentSearch(needle: string, file: string, cwd: string = repoRoot) { + const run = spawnSync('grep', ['-n', needle, file], { cwd, encoding: 'utf8' }); + const both = `${run.stdout ?? ''}${run.stderr ?? ''}`; + return { + status: run.status, + stdout: run.stdout ?? '', + stderr: run.stderr ?? '', + /** grep's own refusal to search the file, on whichever stream it lands. */ + refusedAsBinary: /^grep: .*: binary file matches$|^Binary file .* matches$/im.test(both), + }; +} + describe('objectstack#5425 — the file that started this is readable again', () => { const target = 'packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts'; @@ -299,9 +331,36 @@ describe('objectstack#5425 — the file that started this is readable again', () // The regression this pins is not "the byte is gone", it is "grep can see // the file". grep exits 0 and prints the line; before the fix it printed // `binary file matches` and no line at all. - const out = execFileSync('grep', ['-n', 'includeKey', target], { cwd: repoRoot, encoding: 'utf8' }); - expect(out).toMatch(/includeKey/); - expect(out).not.toMatch(/binary file matches/); + const found = contentSearch('includeKey', target); + expect(found.status, found.stderr).toBe(0); + expect( + found.refusedAsBinary, + 'grep declined to search the file — the objectstack#5425 harm, back again', + ).toBe(false); + expect( + found.stdout, + 'a declined file yields an EMPTY stdout and exit 0, so the printed line is the real pin', + ).toMatch(/^\d+:.*includeKey/m); + }); + + it('the refusal this asserts against is a refusal grep really makes', () => { + // The control. Without it `refusedAsBinary: false` above proves nothing — + // and the spelling it replaced was exactly that: it matched stdout for a + // message GNU grep writes on stderr, so it was false for every file on + // earth. Here grep is handed a file that IS binary and must decline it. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'control-bytes-grep-')); + try { + const probe = path.join(dir, 'probe.ts'); + // U+0000 written from its CODE POINT: a raw control byte in this source + // is precisely what the gate under test refuses. + fs.writeFileSync(probe, `const includeKey = 1;${String.fromCharCode(0)}\n`); + const declined = contentSearch('includeKey', probe, dir); + expect(declined.refusedAsBinary, 'grep must decline a NUL-bearing file').toBe(true); + expect(declined.stdout, 'and print no line at all — that is the search outage').toBe(''); + expect(declined.status, 'while exiting 0, which is what makes the outage silent').toBe(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); }); @@ -334,9 +393,10 @@ describe('objectstack#5450 — the four baselined files are clean', () => { it.each(cleaned.filter((c) => c.grepFor).map((c) => [c.file, c.grepFor as string]))( '%s is visible to a content search again', (file, needle) => { - const out = execFileSync('grep', ['-n', needle, file], { cwd: repoRoot, encoding: 'utf8' }); - expect(out).toMatch(new RegExp(needle)); - expect(out).not.toMatch(/binary file matches/); + const found = contentSearch(needle, file); + expect(found.status, found.stderr).toBe(0); + expect(found.refusedAsBinary, `grep declined to search ${file}`).toBe(false); + expect(found.stdout).toMatch(new RegExp(`^\\d+:.*${needle}`, 'm')); }, ); diff --git a/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index 7114ce6b48..330933d04d 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -23,6 +23,8 @@ import { ROOT_PAGES as COMPONENT_ROOT_PAGES, } from '../check-doc-component-types.mjs'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GUARD = 'scripts/check-doc-fence-languages.mjs'; const WORKFLOW = 'doc-fence-languages.yml'; @@ -269,7 +271,16 @@ describe('check-doc-fence-languages is wired, not merely present', () => { it('its self-test passes — the half that makes a green scan mean something', () => { const out = execFileSync('node', [GUARD, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/check-doc-fence-languages self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/check-doc-fence-languages self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'check-doc-fence-languages'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); /** diff --git a/scripts/__tests__/check-governed-queue-guard.test.ts b/scripts/__tests__/check-governed-queue-guard.test.ts index 380f8b6cf3..6ca6506e02 100644 --- a/scripts/__tests__/check-governed-queue-guard.test.ts +++ b/scripts/__tests__/check-governed-queue-guard.test.ts @@ -14,6 +14,8 @@ import { governedPathsIn, } from '../check-governed-queue-guard.mjs'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GATE = 'scripts/check-governed-queue-guard.mjs'; const WORKFLOW = `.github/workflows/${CHECK_WORKFLOW}`; @@ -123,7 +125,16 @@ describe('check-governed-queue-guard is wired, not merely present', () => { it('its self-test passes — the half that makes a green run mean something', () => { const out = execFileSync('node', [GATE, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/check-governed-queue-guard self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/check-governed-queue-guard self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'check-governed-queue-guard'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); }); diff --git a/scripts/__tests__/check-half-states.test.ts b/scripts/__tests__/check-half-states.test.ts index ddd301c92d..d72d1730bf 100644 --- a/scripts/__tests__/check-half-states.test.ts +++ b/scripts/__tests__/check-half-states.test.ts @@ -17,6 +17,8 @@ import { summaryLine, } from '../pm/check-half-states.mjs'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + /** * objectui#5791 — the half-state patrol, PORTED from objectstack (PR #11294). * @@ -65,7 +67,16 @@ describe('check-half-states — the ported sweeper', () => { encoding: 'utf8', cwd: repoRoot, }); - expect(out).toMatch(/✓ check-half-states self-test: \d+ cases pass\./); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/✓ check-half-states self-test: \d+ cases pass\./); + expect( + selfTestCases(out, 'check-half-states'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); it('lives at the path the workflow invokes', () => { diff --git a/scripts/__tests__/check-pre-install-import-graph.test.ts b/scripts/__tests__/check-pre-install-import-graph.test.ts index 31ac672215..59e7a43856 100644 --- a/scripts/__tests__/check-pre-install-import-graph.test.ts +++ b/scripts/__tests__/check-pre-install-import-graph.test.ts @@ -17,6 +17,8 @@ import { } from '../check-pre-install-import-graph.mjs'; import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + /** * objectui#6148 — the gate for the property that lets a gate run pre-install. * @@ -343,7 +345,16 @@ describe('the gate is wired, not merely present', () => { it('passes its own self-test', () => { // A scan whose recogniser is broken reports a clean tree. const out = execFileSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' }); - expect(out).toContain('self-test:'); - expect(out).toMatch(/^✓/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toContain('self-test:'); + expect(stripAnsi(out)).toMatch(/^✓/); + expect( + selfTestCases(out, 'check-pre-install-import-graph'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); }); diff --git a/scripts/__tests__/child-verdict.test.ts b/scripts/__tests__/child-verdict.test.ts new file mode 100644 index 0000000000..7c03a62a18 --- /dev/null +++ b/scripts/__tests__/child-verdict.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { selfTestCases, stripAnsi, verdictCount } from './helpers/child-verdict'; + +/** + * objectui#7897 — the reader that the child-spawning pin tests in this directory + * share, pinned in the two directions it exists to close. + * + * Both directions are pinned against the OLD spelling as well as the new one. + * A reader that only demonstrated the new spelling working would leave the next + * person free to conclude the two are interchangeable — and they are not: that + * is the entire content of this module. + */ + +/** ANSI escapes built from the code point; a raw control byte here is refused by `pnpm check:control-bytes`. */ +const E = String.fromCharCode(27); + +describe('stripAnsi — the colour CI adds', () => { + /** + * The exact bytes from objectui PR #7889's failing CI job (run 34003883330, + * job 101407488095), rebuilt from the escape's code point: under GitHub + * Actions a child vitest colours its summary, so `Tests ` and `1 failed` are + * separated by SGR sequences rather than by whitespace. + */ + const AS_CI_PRINTED = `${E}[2m Tests ${E}[22m ${E}[1m${E}[31m1 failed${E}[39m${E}[22m${E}[90m (1)${E}[39m`; + + it('the historical defect reproduces: the raw bytes do NOT match the prose regex', () => { + expect(AS_CI_PRINTED, 'green locally, red only in CI -- the shape objectui#7897 sweeps').not.toMatch( + /Tests\s+1 failed/, + ); + }); + + it('and the same bytes match once the SGR sequences are gone', () => { + expect(stripAnsi(AS_CI_PRINTED)).toMatch(/Tests\s+1 failed/); + expect(stripAnsi(AS_CI_PRINTED)).toBe(' Tests 1 failed (1)'); + }); + + it('leaves output that carries no escape at all byte-identical', () => { + const plain = '✓ check-doc-fence-languages self-test: 26 cases pass.\n'; + expect(stripAnsi(plain)).toBe(plain); + }); +}); + +describe('selfTestCases — a count, not a shape', () => { + const REAL = '✓ check-bash32-floor self-test: 155 cases pass.\n'; + /** `check-governed-queue-guard` prefixes `OK` rather than `✓`; the prefix is presentation. */ + const OK_PREFIXED = 'OK check-governed-queue-guard self-test: 132 cases pass (the five ruled surfaces...).\n'; + + it('reads the number out of a real gate verdict, whatever the prefix', () => { + expect(selfTestCases(REAL, 'check-bash32-floor')).toBe(155); + expect(selfTestCases(OK_PREFIXED, 'check-governed-queue-guard')).toBe(132); + }); + + it('reads it through colour, so a gate that starts colouring does not turn every caller red in CI only', () => { + const coloured = `${E}[32m✓ check-entry-guard self-test: ${E}[1m63${E}[22m cases pass${E}[39m`; + expect(coloured, 'the raw bytes do not match -- the SGR sits inside the count').not.toMatch( + /check-entry-guard self-test: \d+ cases pass/, + ); + expect(selfTestCases(coloured, 'check-entry-guard')).toBe(63); + }); + + /** + * ⭐ The non-equivalence pin. `\d+ cases pass` is satisfied by a self-test + * whose case table is EMPTY — it passes for the outcome it exists to refuse, + * and no CI run can catch that, because the assertion is green. + */ + it('the OLD spelling accepts an empty case table; the count refuses it', () => { + const vacuous = '✓ check-bash32-floor self-test: 0 cases pass.\n'; + expect(vacuous, 'the old spelling: a pin satisfied by the absence of what it pins').toMatch( + /check-bash32-floor self-test: \d+ cases pass/, + ); + expect(selfTestCases(vacuous, 'check-bash32-floor')).toBe(0); + // ...which is what every call site now asserts against: + expect(() => expect(selfTestCases(vacuous, 'check-bash32-floor')).toBeGreaterThan(0)).toThrow(); + }); + + it('throws, naming the output, when the verdict is absent rather than reporting zero', () => { + expect(() => selfTestCases('the gate crashed before printing anything\n', 'check-bash32-floor')).toThrow( + /check-bash32-floor self-test case count/, + ); + }); + + it('does not answer about one gate from another gate line', () => { + expect(() => selfTestCases(REAL, 'check-entry-guard')).toThrow(); + }); +}); + +describe('verdictCount — the generic reader', () => { + it('captures the digits the pattern names', () => { + const out = '✓ check-upstream-port-parity: 3 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo...'; + expect(verdictCount(out, /(\d+) ported file\(s\) match/, 'ported file count')).toBe(3); + }); + + it('is not satisfied by a zero the un-captured spelling would accept', () => { + const empty = '✓ check-upstream-port-parity: 0 ported file(s) match objectstack-ai/objectstack@bf10debd5 modulo...'; + expect(empty, 'the old spelling passes on an EMPTY pin').toMatch(/ported file\(s\) match/); + expect(verdictCount(empty, /(\d+) ported file\(s\) match/, 'ported file count')).toBe(0); + }); + + it('throws with the whole output when nothing matches', () => { + expect(() => verdictCount('nothing here\n', /(\d+) widgets/, 'widget count')).toThrow(/nothing here/); + }); +}); diff --git a/scripts/__tests__/entry-guard-wiring.test.ts b/scripts/__tests__/entry-guard-wiring.test.ts index 571447d092..289138c87e 100644 --- a/scripts/__tests__/entry-guard-wiring.test.ts +++ b/scripts/__tests__/entry-guard-wiring.test.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GATE = 'scripts/check-entry-guard.mjs'; @@ -97,6 +99,15 @@ describe('check-entry-guard is wired, not merely present', () => { it('its self-test passes — the half that makes a green scan mean something', () => { const out = execFileSync('node', [GATE, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/check-entry-guard self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/check-entry-guard self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'check-entry-guard'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); }); diff --git a/scripts/__tests__/helpers/child-verdict.ts b/scripts/__tests__/helpers/child-verdict.ts new file mode 100644 index 0000000000..b4c8b7dcc3 --- /dev/null +++ b/scripts/__tests__/helpers/child-verdict.ts @@ -0,0 +1,70 @@ +/** + * Reading a spawned child's verdict WITHOUT depending on how it prints. + * + * objectui#7897. A test that spawns a child process and matches a regex against + * the child's human-readable stdout has two independent failure modes, and both + * of them are silent where it matters: + * + * 1. **Colour.** Under GitHub Actions a child that colours its output puts SGR + * sequences INSIDE the text being matched, so `\s+` — and any pattern that + * spans two coloured spans — stops matching. The assertion is green on + * every local run and red only in CI, which is the expensive direction: it + * is discovered by burning a CI cycle on an unrelated PR. Measured on + * objectui PR #7889 (CI run 34003883330, job 101407488095). + * 2. **A count that admits zero.** `\d+ cases pass` matches `0 cases pass`. + * An assertion shaped that way does not discriminate "the child ran its + * cases" from "the child's case table is empty" — it reads as a pin while + * being satisfied by the outcome it exists to refuse. Nothing in CI catches + * that, ever, because the assertion passes. + * + * The rule this module encodes: read a NUMBER out of the child's verdict and + * assert on the number. ANSI stripping is the second belt, applied here so no + * caller has to remember it — the repo's own gates do not colour (measured on + * `01c27c431`: no ANSI in any `scripts/*.mjs`), but a caller cannot tell that + * from the call site, and `scripts/shadcn-sync.js` in this same tree colours + * unconditionally. + */ + +/** + * ANSI SGR sequences, built from the escape's CODE POINT. A raw control byte in + * this source is exactly what `pnpm check:control-bytes` exists to refuse. + */ +const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +/** The child's output with every SGR sequence removed. */ +export function stripAnsi(text: string): string { + return text.replace(SGR, ''); +} + +/** + * Pull a single count out of a child's verdict line. + * + * Throws — rather than returning a default — when the pattern does not match: + * a missing verdict means the child did not do what the caller thinks it did, + * and a `0` returned quietly there would be indistinguishable from a real zero. + * The whole output rides on the error so the failure names itself. + * + * @param output the child's stdout (and stderr, if the caller joined them) + * @param pattern a regex with exactly ONE capturing group, the digits + * @param what what the number counts, for the error message + */ +export function verdictCount(output: string, pattern: RegExp, what: string): number { + const plain = stripAnsi(output); + const match = plain.match(pattern); + if (!match) { + throw new Error(`no ${what} in the child's verdict -- ${pattern} did not match:\n${plain}`); + } + return Number(match[1]); +} + +/** + * The number of cases a repo gate's `--self-test` reports passing. + * + * Every gate in this tree ends its self-test with ` self-test: N cases + * pass`, some prefixed `✓`, one prefixed `OK`. The prefix is presentation and is + * deliberately not matched. + */ +export function selfTestCases(output: string, gate: string): number { + const escaped = gate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return verdictCount(output, new RegExp(`${escaped} self-test: (\\d+) cases pass`), `${gate} self-test case count`); +} diff --git a/scripts/__tests__/js-comment-mask-jsx-6891.test.ts b/scripts/__tests__/js-comment-mask-jsx-6891.test.ts index 00acd442b6..19e69aeae0 100644 --- a/scripts/__tests__/js-comment-mask-jsx-6891.test.ts +++ b/scripts/__tests__/js-comment-mask-jsx-6891.test.ts @@ -9,6 +9,8 @@ import { describe, expect, it } from 'vitest'; import { findCallSites } from '../check-vi-mock-specifiers.mjs'; import { maskComments, scanSource } from '../js-comment-mask.mjs'; +import { selfTestCases, stripAnsi } from './helpers/child-verdict'; + /** * objectui#6891 — a JSX closing tag is not a regex literal. * @@ -140,7 +142,16 @@ describe('the negative controls — a `/` that really does open a regex', () => const out = execFileSync('node', [MODULE, '--self-test'], { cwd: REPO_ROOT, encoding: 'utf8' }); expect(out).toContain('a JSX closing tag opens no span'); expect(out).toContain('a SPACED less-than still opens a regex'); - expect(out).toMatch(/self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring — + // that is the CI-only direction, and no repo gate colours today. + expect(stripAnsi(out)).toMatch(/self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'js-comment-mask'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); }); diff --git a/scripts/__tests__/shadcn-sync-fetch-cache.test.ts b/scripts/__tests__/shadcn-sync-fetch-cache.test.ts index 96ec590735..cff66ba795 100644 --- a/scripts/__tests__/shadcn-sync-fetch-cache.test.ts +++ b/scripts/__tests__/shadcn-sync-fetch-cache.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import http from 'node:http'; import { EventEmitter } from 'node:events'; import type { AddressInfo } from 'node:net'; +import fs from 'node:fs'; import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -9,6 +10,7 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { fetchUrl, fetchRegistry, isRegistryEntry, cacheFileFor, cacheStats } from '../shadcn-sync.js'; +import { stripAnsi } from './helpers/child-verdict'; /** * objectstack#5803 — the registry cache stored whatever came back. @@ -361,6 +363,28 @@ describe('the CLI still runs when the file is the entry point', () => { // (everything above) does not execute the CLI. Get that guard wrong and // `pnpm shadcn:check` becomes a silent no-op that exits 0 — so it is pinned // by actually running the script. + /** + * objectui#7897 — this child COLOURS, and the old assertions survived it by + * luck. + * + * `scripts/shadcn-sync.js` writes SGR sequences unconditionally: no tty + * check, no `NO_COLOR`, so the escapes are there on every run, local and CI + * alike. `Component List` and `Custom ObjectUI Components:` happened to be + * wrapped whole (`ESC[1m` + title + `ESC[0m`), so a substring match on the + * raw stdout still hit — but every per-component line puts an escape BETWEEN + * the name and the description, which is the same byte layout that broke + * PR #7889 in CI. Anything asserted across that boundary needs the strip. + * + * There is no machine-readable channel to prefer here: `--list` has no JSON + * mode, and objectui#7897's surface is test files only — ⛔ no gate script + * changes. So ANSI stripping is the belt, and the assertion is anchored to + * the manifest the printer reads, which is the machine-readable half that IS + * available: every custom component must appear, spelled exactly as the + * printer formats it. That also closes the second half of the card — the old + * pair of substring checks passed on a run that printed the two HEADERS and + * no components at all, which is exactly the silent no-op the case exists to + * refuse. + */ it('node scripts/shadcn-sync.js --list prints the component list', () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const result = spawnSync(process.execPath, ['scripts/shadcn-sync.js', '--list'], { @@ -368,8 +392,25 @@ describe('the CLI still runs when the file is the entry point', () => { encoding: 'utf-8', timeout: 60_000, }); - expect(result.status).toBe(0); - expect(result.stdout).toContain('Component List'); - expect(result.stdout).toContain('Custom ObjectUI Components:'); + expect(result.status, result.stderr).toBe(0); + const plain = stripAnsi(result.stdout); + expect(plain).toContain('Component List'); + expect(plain).toContain('Custom ObjectUI Components:'); + + const manifest = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'packages/components/shadcn-components.json'), 'utf-8'), + ) as { components: Record; customComponents: Record }; + + const shadcnNames = Object.keys(manifest.components); + const customEntries = Object.entries(manifest.customComponents); + expect(shadcnNames.length, 'an empty manifest would make every assertion below vacuous').toBeGreaterThan(0); + expect(customEntries.length, 'an empty manifest would make every assertion below vacuous').toBeGreaterThan(0); + + for (const name of shadcnNames) expect(plain).toContain(`\n • ${name}\n`); + // `name.padEnd(20)` then one space then the description — the printer's own + // format, and the escape the strip removed sat exactly at that space. + for (const [name, info] of customEntries) { + expect(plain).toContain(` • ${name.padEnd(20)} ${info.description}`); + } }); }); diff --git a/scripts/__tests__/upstream-port-parity-wiring.test.ts b/scripts/__tests__/upstream-port-parity-wiring.test.ts index ca3a824e78..e8fbc4baa8 100644 --- a/scripts/__tests__/upstream-port-parity-wiring.test.ts +++ b/scripts/__tests__/upstream-port-parity-wiring.test.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +import { selfTestCases, stripAnsi, verdictCount } from './helpers/child-verdict'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GATE = 'scripts/check-upstream-port-parity.mjs'; const PIN = 'scripts/upstream-port-pin.json'; @@ -162,7 +164,15 @@ describe('check-upstream-port-parity is wired, not merely present', () => { it('its self-test passes — the half that makes a green comparison mean something', () => { const out = execFileSync('node', [GATE, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/check-upstream-port-parity self-test: \d+ cases pass/); + // objectui#7897 — the COUNT, not the shape. `\d+ cases pass` is satisfied + // by `0 cases pass`, so the old spelling passed for a self-test whose case + // table had gone empty: the outcome it exists to refuse. `selfTestCases` + // also strips ANSI, the second belt for a child that starts colouring. + expect(stripAnsi(out)).toMatch(/check-upstream-port-parity self-test: \d+ cases pass/); + expect( + selfTestCases(out, 'check-upstream-port-parity'), + 'a self-test that ran no cases is not a passing self-test', + ).toBeGreaterThan(0); }); it('and the tree itself is at parity right now', () => { @@ -171,6 +181,13 @@ describe('check-upstream-port-parity is wired, not merely present', () => { // updated without its file, or the reverse, fails here at review time // rather than on someone else's branch. const out = execFileSync('node', [GATE], { cwd: ROOT, encoding: 'utf8' }); - expect(out).toMatch(/ported file\(s\) match/); + // objectui#7897 — `/ported file\(s\) match/` is satisfied by `0 ported + // file(s) match`: an EMPTY pin, checked against nothing, reads exactly like + // a tree at parity. The count is read out of the verdict and reconciled + // with the pin shipped in this commit, so the two cannot drift apart + // silently. ANSI is stripped as the second belt (this gate does not colour). + const pinned = JSON.parse(fs.readFileSync(path.join(ROOT, PIN), 'utf8')) as { files: unknown[] }; + expect(pinned.files.length, 'a pin with no files would make the verdict below vacuous').toBeGreaterThan(0); + expect(verdictCount(out, /(\d+) ported file\(s\) match/, 'ported file count')).toBe(pinned.files.length); }); });