From 9f7c932134df0b820700e7e026dca92b56474f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 01:57:46 +0000 Subject: [PATCH] test(ui-merge): the ui gate-wiring pins read the root ci.yml that runs them; pins on jobs it did not port retire GitHub runs only the repository root's .github/workflows/. objectui's ci.yml under ui/.github/ runs nowhere, yet 28 ui/scripts/__tests__ files read it. - New scripts/__tests__/helpers/root-ci.ts reads the root ci.yml by path (workspaceRoot()), slices a job, reads the filter job's ui: globs, and spells objectui's commands as `pnpm --dir ui run `. - 17 files retarget their wiring pins to the root type-check-ui, test-ui and build-e2e-ui jobs. The path-filter pins now assert that the root triggers carry no path filter and that the ui: filter globs cover each gate's surface. - 11 files retire pins on objectui jobs the root did not port: the coverage lane, Build Docs, the shard aggregate, the timings upload, the affected-test and type-check-scope selectors, objectui's turbo-cache rule and its cache-save bound. Each retirement comment names the workflow roster row that carries it. Four of these files had nothing else to pin and are deleted, and markdown-test-inputs.mjs drops the two ledger rows whose literals went with them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01A6SEitNngAMugGbuJHWsDS --- .../check-action-forward-parity.test.ts | 80 ++- .../check-doc-example-shared-reader.test.ts | 12 +- .../check-doc-expression-carriage.test.ts | 10 +- .../check-handler-key-read-sites.test.ts | 12 +- .../check-i18n-call-site-keys.test.ts | 13 +- .../check-i18n-designer-table-parity.test.ts | 33 +- .../__tests__/check-i18n-en-drift.test.ts | 64 +-- .../check-lucide-icon-record-names.test.ts | 8 +- .../check-object-metadata-write-doors.test.ts | 6 +- .../check-package-self-import.test.ts | 21 +- .../check-pending-changeset-literals.test.ts | 26 +- .../check-phantom-dependencies.test.ts | 21 +- ...published-tsconfig-tooling-exclude.test.ts | 16 +- .../check-test-shard-results.test.ts | 120 +---- .../check-unreferenced-sources.test.ts | 21 +- ...overage-shard-reporter-readability.test.ts | 165 ------- .../__tests__/coverage-shard-width.test.ts | 118 ----- .../__tests__/docs-build-trigger.test.ts | 466 ------------------ .../__tests__/ensure-chromium-ready.test.ts | 20 +- ui/scripts/__tests__/helpers/root-ci.ts | 112 +++++ .../__tests__/report-turbo-cache.test.ts | 172 +------ .../__tests__/scripts-type-check.test.ts | 22 +- .../__tests__/select-affected-tests.test.ts | 44 +- .../__tests__/select-type-check-scope.test.ts | 82 +-- ui/scripts/__tests__/shard-headroom.test.ts | 41 +- .../__tests__/test-file-timings.test.ts | 65 +-- .../__tests__/turbo-cache-save-bound.test.ts | 214 -------- .../vitest-config-type-check.test.ts | 31 +- .../__tests__/vitest-setup-type-check.test.ts | 31 +- ui/scripts/markdown-test-inputs.mjs | 17 - 30 files changed, 447 insertions(+), 1616 deletions(-) delete mode 100644 ui/scripts/__tests__/coverage-shard-reporter-readability.test.ts delete mode 100644 ui/scripts/__tests__/coverage-shard-width.test.ts delete mode 100644 ui/scripts/__tests__/docs-build-trigger.test.ts create mode 100644 ui/scripts/__tests__/helpers/root-ci.ts delete mode 100644 ui/scripts/__tests__/turbo-cache-save-bound.test.ts diff --git a/ui/scripts/__tests__/check-action-forward-parity.test.ts b/ui/scripts/__tests__/check-action-forward-parity.test.ts index b6322af0c41..163618cabb6 100644 --- a/ui/scripts/__tests__/check-action-forward-parity.test.ts +++ b/ui/scripts/__tests__/check-action-forward-parity.test.ts @@ -22,6 +22,17 @@ import { SURFACES, RUNTIME_CONSUMERS, } from '../check-action-forward-parity.mjs'; +import { + globMatches, + jobBlock, + repoPath, + rootCi, + triggerBlock, + uiFilterGlobs, + uiRun, + UI_JOBS, + withoutComments, +} from './helpers/root-ci'; /** * objectui#4050 — the behaviour test for `scripts/check-action-forward-parity.mjs`. @@ -723,60 +734,47 @@ describe('the gate is wired to run', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record; }; - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const ci = rootCi(); + const job = jobBlock(ci, UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:action-forward-parity']).toBe('node scripts/check-action-forward-parity.mjs'); }); it('ci.yml runs it after the install it needs (it resolves @objectstack/spec)', () => { - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:action-forward-parity'); - expect(step, 'ci.yml does not run `pnpm check:action-forward-parity`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:action-forward-parity')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:action-forward-parity`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); it('is not hidden from the renderers by ci.yml path filters', () => { - // ci.yml `paths-ignore`s markdown, content/, docs/, apps/site/ and - // .changeset/ on its `push` trigger — since objectui#3523 step 2 the only - // trigger that still carries the filter (ci.yml:6; lint.yml:32 is the twin), - // and the only thing the assertion below can see, since it slices the `on:` - // block. None can match `packages/components/src/renderers/**`, so a push - // that edits a forward whitelist always starts this workflow. - // - // For a PULL REQUEST the conclusion holds for a second and stronger reason. - // This lead-in used to state it in a bare present tense that read as if the - // filter still applied there; objectui#4384 qualified it, together with its - // near-verbatim twin in `check-i18n-en-drift.test.ts`, closing the #3857 / - // #4369 / #4381 family (PRs #4371 / #4380 / #4382). No `paths-ignore` - // survives on `pull_request` at all, so such a PR starts this workflow a - // fortiori — measured there: PR #3856, one markdown file, 16 checks. What - // decides a pull request now is the in-job `Decide whether this change needs - // a full run` step, whose exclusion list is that `push` filter unchanged, - // held identical to it by `scripts/__tests__/merge-queue-reporting.test.ts` - // — so the patterns pinned below are also what keeps the expensive steps - // running on a renderer PR. - const ignored = (ci.slice(0, ci.indexOf('jobs:')).match(/^\s+- '.*'$/gm) ?? []).map((line) => - line.trim().replace(/^- '/, '').replace(/'$/, ''), + // The root ci.yml carries no `paths-ignore` on any trigger: it starts on + // every pull_request, every queue build, every push to main and the hourly + // schedule. What decides whether `type-check-ui` then runs is the `filter` + // job's `ui:` paths filter, which the job's `if:` reads, so every renderer + // a forward whitelist lives in must match one of its globs or a pull + // request that edits one skips this gate. (Until objectstack#25712 this + // pinned objectui's own `push` `paths-ignore` list, in a file nothing runs.) + expect(withoutComments(triggerBlock(ci)), 'a trigger grew a path filter').not.toMatch(/^\s+paths(-ignore)?:/m); + expect(job, '`type-check-ui` no longer reads the `ui` filter output').toContain( + "needs.filter.outputs.ui != 'false'", ); - expect(ignored.length, 'the paths-ignore parse found nothing — this pin would be vacuous').toBeGreaterThan(3); - - /** `**` crosses separators, `*` does not — enough for the five patterns above. */ - const matches = (glob: string, file: string): boolean => - new RegExp( - `^${glob - .split('**') - .map((part) => part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')) - .join('.*')}$`, - ).test(file); + const globs = uiFilterGlobs(ci); for (const surface of SURFACES) { - for (const glob of ignored) { - expect(matches(glob, surface.file), `${glob} hides ${surface.file}`).toBe(false); - } - // The matcher itself must be able to say yes, or the loop above proves - // nothing — the vacuity trap this whole file is about. - expect(matches('**/*.md', 'content/docs/guide/ci-cd-pipeline.md')).toBe(true); + const file = repoPath(surface.file); + expect( + globs.some((glob) => globMatches(glob, file)), + `no \`ui:\` filter glob covers ${file}`, + ).toBe(true); } + // The matcher itself must be able to say no, or the loop above proves + // nothing — the vacuity trap this whole file is about. + expect(globs.some((glob) => globMatches(glob, 'packages/spec/src/index.ts'))).toBe(false); }); }); diff --git a/ui/scripts/__tests__/check-doc-example-shared-reader.test.ts b/ui/scripts/__tests__/check-doc-example-shared-reader.test.ts index 72466e0508f..82a715c5d8d 100644 --- a/ui/scripts/__tests__/check-doc-example-shared-reader.test.ts +++ b/ui/scripts/__tests__/check-doc-example-shared-reader.test.ts @@ -15,6 +15,7 @@ import { inline, rungsOf, } from '../check-doc-example-shared-reader.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#7652 — a doc comment must not prescribe a call-site spelling that a @@ -482,10 +483,13 @@ describe('check-doc-example-shared-reader — this repository', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(manifest.scripts['check:doc-example-readers']).toBe('node scripts/check-doc-example-shared-reader.mjs'); - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - expect(ci, 'the gate must run in CI, next to the other source-parsing gates').toContain( - 'run: pnpm check:doc-example-readers', - ); + // objectstack's root ci.yml runs objectui's gates: its `type-check-ui` job is + // objectui's `Type Check`, where the source-parsing gates run (objectstack#25697). + // The ci.yml under `ui/.github/` runs nowhere (objectstack#25712). + expect( + jobBlock(rootCi(), UI_JOBS.typeCheck), + 'the gate must run in CI, next to the other source-parsing gates', + ).toContain(`run: ${uiRun('check:doc-example-readers')}`); // The page that inventories the gates is pinned by command (objectui#3653), so // this would fail there too — asserted here as well because a reader looking diff --git a/ui/scripts/__tests__/check-doc-expression-carriage.test.ts b/ui/scripts/__tests__/check-doc-expression-carriage.test.ts index c9202c68402..58b1b1a0593 100644 --- a/ui/scripts/__tests__/check-doc-expression-carriage.test.ts +++ b/ui/scripts/__tests__/check-doc-expression-carriage.test.ts @@ -848,10 +848,12 @@ describe('check-doc-expression-carriage: the real tree, and the posture', () => }); describe('check-doc-expression-carriage: the wiring', () => { - it('is actually run by ci.yml — a gate nobody runs cannot be told from one that passes', () => { - const ci = fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'); - expect(ci).toContain(`node ${GATE}`); - }); + // `is actually run by ci.yml` retired with objectstack#25712. The census ran, + // report-only, in objectui ci.yml's `docs` job (`Build Docs`), and that file + // runs nowhere under the monorepo. The job is row 7 `Build Docs + // (@object-ui/site)` of the objectstack#25697 workflow roster, "kept, + // follow-up port": no root job runs the census until that port lands, and + // the port brings this pin back against its job (`helpers/root-ci.ts`). it('is named on the page that documents what CI runs', () => { const page = fs.readFileSync(path.join(ROOT, 'content/docs/guide/ci-cd-pipeline.md'), 'utf8'); diff --git a/ui/scripts/__tests__/check-handler-key-read-sites.test.ts b/ui/scripts/__tests__/check-handler-key-read-sites.test.ts index ed9ac9bfc4d..f891e5ef134 100644 --- a/ui/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/ui/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -13,6 +13,7 @@ import { registrationsIn, parseSource, } from '../check-handler-key-read-sites.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#7753 — every `on*` key a registered renderer READS off the authored @@ -1148,10 +1149,13 @@ describe('check-handler-key-read-sites — this repository', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(manifest.scripts['check:handler-key-reads']).toBe('node scripts/check-handler-key-read-sites.mjs'); - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - expect(ci, 'the gate must run in CI, next to the other source-parsing gates').toContain( - 'run: pnpm check:handler-key-reads', - ); + // objectstack's root ci.yml runs objectui's gates: its `type-check-ui` job is + // objectui's `Type Check`, where the source-parsing gates run (objectstack#25697). + // The ci.yml under `ui/.github/` runs nowhere (objectstack#25712). + expect( + jobBlock(rootCi(), UI_JOBS.typeCheck), + 'the gate must run in CI, next to the other source-parsing gates', + ).toContain(`run: ${uiRun('check:handler-key-reads')}`); // The page that inventories the gates is pinned by command (objectui#3653), so // this would fail there too — asserted here as well because a reader looking diff --git a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts index 4e23e56c2f1..c2d0e4b0333 100644 --- a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts +++ b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -20,6 +20,7 @@ import { RESERVED_OPTION_NAMES, unresolvableSpellings, } from '../check-i18n-call-site-keys.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#3530 — the behaviour test for `scripts/check-i18n-call-site-keys.mjs`. @@ -2129,10 +2130,14 @@ describe('the gate is wired to run', () => { }); it('ci.yml runs it after the install it needs (it imports typescript)', () => { - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('pnpm check:i18n-keys'); - expect(step, 'ci.yml does not run `pnpm check:i18n-keys`').toBeGreaterThan(-1); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const job = jobBlock(rootCi(), UI_JOBS.typeCheck); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:i18n-keys')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-keys`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); }); diff --git a/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts b/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts index 950af86ee76..1b34d63cca4 100644 --- a/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts +++ b/ui/scripts/__tests__/check-i18n-designer-table-parity.test.ts @@ -13,6 +13,7 @@ import { readDesignerPairs, ZH_ONLY_FAMILIES, } from '../check-i18n-designer-table-parity.mjs'; +import { globMatches, jobBlock, repoPath, rootCi, triggerBlock, uiFilterGlobs, uiRun, UI_JOBS, withoutComments } from './helpers/root-ci'; import { DESIGNER_TABLE_CONSTS } from '../check-i18n-dead-keys.mjs'; /** @@ -322,7 +323,11 @@ describe('the CLI, end to end', () => { describe('the gate is wired to run', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const ci = rootCi(); + const job = jobBlock(ci, UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:i18n-designer-parity']).toBe('node scripts/check-i18n-designer-table-parity.mjs'); @@ -333,18 +338,26 @@ describe('the gate is wired to run', () => { // check-i18n-dead-keys.mjs: that one is invoked in no workflow at all, by // design ("This is a REPORT, not a gate"), so a leg there could never be // observed red in CI — the disease objectui#8834 exists to cure. - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:i18n-designer-parity'); - expect(step, 'ci.yml does not run `pnpm check:i18n-designer-parity`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:i18n-designer-parity')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-designer-parity`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); it('is not hidden from the designer table by ci.yml path filters', () => { - // Same reasoning as check-i18n-en-drift.test.ts's twin assertion: an ignore - // entry that covered `packages/` would make this gate unreachable for - // exactly the PRs it judges. - const ignored = ci.slice(0, ci.indexOf('jobs:')).match(/^\s+- '.*'$/gm) ?? []; - for (const pattern of ignored) expect(pattern).not.toMatch(/packages|scripts/); - expect(ignored.length).toBeGreaterThan(0); + // Same reasoning as check-i18n-en-drift.test.ts's twin assertion: the root + // ci.yml's triggers carry no path filter, and the `filter` job's `ui:` globs + // decide whether `type-check-ui` runs, so a glob set that missed the + // designer table or this gate would make it unreachable for exactly the + // PRs it judges. + expect(withoutComments(triggerBlock(ci)), 'a trigger grew a path filter').not.toMatch(/^\s+paths(-ignore)?:/m); + expect(job, '`type-check-ui` no longer reads the `ui` filter output').toContain( + "needs.filter.outputs.ui != 'false'", + ); + const globs = uiFilterGlobs(ci); + for (const file of [DESIGNER_TABLE, 'scripts/check-i18n-designer-table-parity.mjs'].map(repoPath)) { + expect(globs.some((glob) => globMatches(glob, file)), `no \`ui:\` filter glob covers ${file}`).toBe(true); + } }); }); diff --git a/ui/scripts/__tests__/check-i18n-en-drift.test.ts b/ui/scripts/__tests__/check-i18n-en-drift.test.ts index c5dc93b0b13..8fde4b5cbbd 100644 --- a/ui/scripts/__tests__/check-i18n-en-drift.test.ts +++ b/ui/scripts/__tests__/check-i18n-en-drift.test.ts @@ -25,6 +25,7 @@ import { readDesignerTables, } from '../check-i18n-en-drift.mjs'; import { DESIGNER_TABLE } from '../check-i18n-designer-table-parity.mjs'; +import { globMatches, jobBlock, repoPath, rootCi, triggerBlock, uiFilterGlobs, uiRun, UI_JOBS, withoutComments } from './helpers/root-ci'; /** * objectui#3650 — the behaviour test for `scripts/check-i18n-en-drift.mjs`. @@ -964,16 +965,21 @@ describe('resolving the commit to compare against', () => { describe('the gate is wired to run', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const ci = rootCi(); + const job = jobBlock(ci, UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:i18n-drift']).toBe('node scripts/check-i18n-en-drift.mjs'); }); it('ci.yml runs it after the install it needs (it imports typescript)', () => { - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:i18n-drift'); - expect(step, 'ci.yml does not run `pnpm check:i18n-drift`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:i18n-drift')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-drift`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); @@ -981,38 +987,32 @@ describe('the gate is wired to run', () => { // actions/checkout defaults to depth 1. Without this the gate cannot resolve // a base — and by design that is a red build, not a silent pass, so losing // this line breaks CI loudly. The pin is here so the reason survives. - const job = ci.slice(ci.indexOf(' type-check:'), ci.indexOf('run: pnpm check:i18n-drift')); - expect(job).toContain('fetch-depth: 0'); + const step = job.indexOf(`run: ${uiRun('check:i18n-drift')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-drift`').toBeGreaterThan(-1); + expect(job.slice(0, step)).toContain('fetch-depth: 0'); }); it('is not hidden from the locale packs by ci.yml path filters', () => { - // ci.yml `paths-ignore`s markdown, content/, docs/, apps/site/ and - // .changeset/ on its `push` trigger — since objectui#3523 step 2 the only - // trigger that still carries the filter (ci.yml:6; lint.yml:32 is the twin), - // and the only thing the assertion below can see, since it slices the `on:` - // block. None of them can match `packages/i18n/src/locales/*.ts` or the - // ledger, so a push that edits an en string always starts this workflow. + // The root ci.yml carries no `paths-ignore` on any trigger: it starts on + // every pull_request, every queue build, every push to main and the hourly + // schedule. What decides whether `type-check-ui` then runs is the `filter` + // job's `ui:` paths filter, which the job's `if:` reads, so the ten locale + // packs, the ledger and the designer table must each match one of its + // globs. (Until objectstack#25712 this pinned objectui's own `push` + // `paths-ignore` list and its in-job `Decide whether this change needs a + // full run` step, in a ci.yml nothing runs; its near-verbatim twin is in + // `check-action-forward-parity.test.ts`.) // - // For a PULL REQUEST the conclusion holds for a second and stronger reason. - // This lead-in used to state it in a bare present tense that read as if the - // filter still applied there; objectui#4384 qualified it, together with its - // near-verbatim twin in `check-action-forward-parity.test.ts`, closing the - // #3857 / #4369 / #4381 family (PRs #4371 / #4380 / #4382). No - // `paths-ignore` survives on `pull_request` at all, so such a PR starts this - // workflow a fortiori — measured there: PR #3856, one markdown file, 16 - // checks. What decides a pull request now is the in-job `Decide whether this - // change needs a full run` step, whose exclusion list is that `push` filter - // unchanged, held identical to it by - // `scripts/__tests__/merge-queue-reporting.test.ts` — so the patterns pinned - // below are also what keeps the expensive steps running on a locale-pack PR. - // - // A new entry that DID cover them would make the gate unreachable for - // exactly the PRs it judges, on both lanes at once — the objectui#3547 / - // control-bytes.yml lesson, one workflow over. - const ignored = ci.slice(0, ci.indexOf('jobs:')).match(/^\s+- '.*'$/gm) ?? []; - for (const pattern of ignored) { - expect(pattern).not.toMatch(/packages|scripts|locales/); + // A glob set that missed them would make the gate unreachable for exactly + // the PRs it judges — the objectui#3547 / control-bytes.yml lesson, one + // workflow over. + expect(withoutComments(triggerBlock(ci)), 'a trigger grew a path filter').not.toMatch(/^\s+paths(-ignore)?:/m); + expect(job, '`type-check-ui` no longer reads the `ui` filter output').toContain( + "needs.filter.outputs.ui != 'false'", + ); + const globs = uiFilterGlobs(ci); + for (const file of [...LOCALES.map(packPath), LEDGER_PATH, DESIGNER_TABLE].map(repoPath)) { + expect(globs.some((glob) => globMatches(glob, file)), `no \`ui:\` filter glob covers ${file}`).toBe(true); } - expect(ignored.length).toBeGreaterThan(0); }); }); diff --git a/ui/scripts/__tests__/check-lucide-icon-record-names.test.ts b/ui/scripts/__tests__/check-lucide-icon-record-names.test.ts index da1a52c0fcc..4f659eb142e 100644 --- a/ui/scripts/__tests__/check-lucide-icon-record-names.test.ts +++ b/ui/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -20,6 +20,7 @@ import { selfTest, toRecordKey, } from '../check-lucide-icon-record-names.mjs'; +import { rootCi, uiRun } from './helpers/root-ci'; // ⭐ objectui#5935 — THE resolver itself, imported so this file can EXECUTE it // rather than only read its text. TypeScript straight out of `packages/` in a @@ -1014,8 +1015,11 @@ describe('the gate is wired and the local pins it subsumes are gone', () => { const entry = Object.entries(scripts).find(([, command]) => command.includes(GATE)); expect(entry, `no root script runs ${GATE}`).toBeDefined(); - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - expect(ci, `${GATE} is not run by ci.yml — an unrun gate is not a gate`).toContain(`pnpm ${entry![0]}`); + // objectstack's root ci.yml is the workflow that runs objectui's gates + // (objectstack#25697); the ci.yml under `ui/.github/` runs nowhere (#25712). + expect(rootCi(), `${GATE} is not run by ci.yml — an unrun gate is not a gate`).toContain( + `run: ${uiRun(entry![0])}`, + ); }); it('the two fully-subsumed local pins are deleted', () => { diff --git a/ui/scripts/__tests__/check-object-metadata-write-doors.test.ts b/ui/scripts/__tests__/check-object-metadata-write-doors.test.ts index c4eae772690..acb9da45900 100644 --- a/ui/scripts/__tests__/check-object-metadata-write-doors.test.ts +++ b/ui/scripts/__tests__/check-object-metadata-write-doors.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyze } from '../check-object-metadata-write-doors.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#8676 — every in-repo DOOR that can PUT an object-metadata document @@ -290,7 +291,8 @@ describe('wiring', () => { }); it('runs in CI, beside the sibling gates that parse sources with `typescript`', () => { - const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - expect(workflow).toContain('run: pnpm check:metadata-write-doors'); + // objectstack's root ci.yml: `type-check-ui` is objectui's `Type Check` + // (objectstack#25697); the ci.yml under `ui/.github/` runs nowhere (#25712). + expect(jobBlock(rootCi(), UI_JOBS.typeCheck)).toContain(`run: ${uiRun('check:metadata-write-doors')}`); }); }); diff --git a/ui/scripts/__tests__/check-package-self-import.test.ts b/ui/scripts/__tests__/check-package-self-import.test.ts index 88514c728f1..6acd20d9c31 100644 --- a/ui/scripts/__tests__/check-package-self-import.test.ts +++ b/ui/scripts/__tests__/check-package-self-import.test.ts @@ -13,6 +13,7 @@ import { judgeScan, scanRepository, } from '../check-package-self-import.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#4801 — a file inside a package must not name its OWN package. @@ -585,25 +586,25 @@ describe('the gate is wired where the sibling gates are', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record; }; - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const job = jobBlock(rootCi(), UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:self-import']).toBe(`node ${GATE}`); }); it('ci.yml runs it after the install it needs (it parses with typescript)', () => { - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:self-import'); - expect(step, 'ci.yml does not run `pnpm check:self-import`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:self-import')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:self-import`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); - it('runs in the `type-check` job, where the other parse-based gates run', () => { - const jobs = ci.slice(ci.search(/^jobs:[ \t]*$/m)); - const typeCheck = jobs.slice(jobs.search(/^ {2}type-check:[ \t]*$/m)); - const nextJob = typeCheck.slice(1).search(/^ {2}\S/m); - const block = nextJob === -1 ? typeCheck : typeCheck.slice(0, nextJob + 1); - expect(block).toContain('run: pnpm check:self-import'); + it('runs in the `type-check-ui` job, where the other parse-based gates run', () => { + expect(job).toContain(`run: ${uiRun('check:self-import')}`); }); it('shares the sibling gate\'s parser rather than growing a second one', () => { diff --git a/ui/scripts/__tests__/check-pending-changeset-literals.test.ts b/ui/scripts/__tests__/check-pending-changeset-literals.test.ts index 04caa4bd9d3..fd4ef0d8f5d 100644 --- a/ui/scripts/__tests__/check-pending-changeset-literals.test.ts +++ b/ui/scripts/__tests__/check-pending-changeset-literals.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'; // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. import { findings, isPendingDeclaration } from '../check-pending-changeset-literals.mjs'; import { markdownTestInputsAmong } from '../markdown-test-inputs.mjs'; +import { globMatches, jobBlock, repoPath, rootCi, uiFilterGlobs, UI_JOBS } from './helpers/root-ci'; /** * Pins for `scripts/check-pending-changeset-literals.mjs` (objectui#9583). @@ -187,16 +188,21 @@ describe('4. the wiring — both shapes that can break the rule reach this suite }); it('a change to a TEST SOURCE is not excluded from the full run', () => { - // `ci.yml`'s `Decide whether this change needs a full run` step drops paths - // with `:(exclude,glob)` pathspecs. None of the scan roots a test can live - // under may be among them, or a pull request could add a naming literal - // while this suite never runs. - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - const excluded = [...ci.matchAll(/':\(exclude,glob\)([^']+)'/g)].map((m) => m[1]); - - expect(excluded.length, 'the exclusion list must still be readable, or this pin asserts nothing').toBeGreaterThan(0); - expect(excluded).not.toContain('scripts/**'); - expect(excluded).not.toContain('packages/**'); + // objectstack's root ci.yml runs this suite in `test-ui` (objectui's `Test`, + // objectstack#25697), whole, whenever the `filter` job's `ui:` paths filter + // matched; its triggers carry no path filter. Every scan root a test can + // live under must match one of those globs, or a pull request could add a + // naming literal while this suite never runs. (Until objectstack#25712 this + // read the `:(exclude,glob)` pathspecs of objectui's own `Decide whether + // this change needs a full run` step, in a ci.yml nothing runs.) + const ci = rootCi(); + expect(jobBlock(ci, UI_JOBS.test), '`test-ui` no longer reads the `ui` filter output').toContain( + "needs.filter.outputs.ui != 'false'", + ); + const globs = uiFilterGlobs(ci); + for (const file of ['scripts/__tests__/fixture.test.ts', 'packages/fixture/src/fixture.test.ts'].map(repoPath)) { + expect(globs.some((glob) => globMatches(glob, file)), `no \`ui:\` filter glob covers ${file}`).toBe(true); + } }); it('a change that ADDS a declaration is rescued into the full run', () => { diff --git a/ui/scripts/__tests__/check-phantom-dependencies.test.ts b/ui/scripts/__tests__/check-phantom-dependencies.test.ts index 129b75837e7..9f771b8757e 100644 --- a/ui/scripts/__tests__/check-phantom-dependencies.test.ts +++ b/ui/scripts/__tests__/check-phantom-dependencies.test.ts @@ -18,6 +18,7 @@ import { packageNameOf, readReleaseGroup, } from '../check-phantom-dependencies.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#4394 — a bare specifier resolving from a package directory says @@ -727,25 +728,25 @@ describe('the gate is wired where the sibling gates are', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record; }; - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const job = jobBlock(rootCi(), UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:phantom-deps']).toBe('node scripts/check-phantom-dependencies.mjs'); }); it('ci.yml runs it after the install it needs (it imports typescript)', () => { - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:phantom-deps'); - expect(step, 'ci.yml does not run `pnpm check:phantom-deps`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:phantom-deps')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:phantom-deps`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); - it('runs in the `type-check` job, where the other parse-based gates run', () => { - const jobs = ci.slice(ci.search(/^jobs:[ \t]*$/m)); - const typeCheck = jobs.slice(jobs.search(/^ {2}type-check:[ \t]*$/m)); - const nextJob = typeCheck.slice(1).search(/^ {2}\S/m); - const block = nextJob === -1 ? typeCheck : typeCheck.slice(0, nextJob + 1); - expect(block).toContain('run: pnpm check:phantom-deps'); + it('runs in the `type-check-ui` job, where the other parse-based gates run', () => { + expect(job).toContain(`run: ${uiRun('check:phantom-deps')}`); }); it('the released packages it scans are the ones changesets versions', () => { diff --git a/ui/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts b/ui/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts index 2c42dda8732..5c33735d7fb 100644 --- a/ui/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts +++ b/ui/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts @@ -18,6 +18,7 @@ import { missingDirectories, stripJsonComments, } from '../check-published-tsconfig-tooling-exclude.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#7212 — every published package's build tsconfig must exclude tooling @@ -373,15 +374,20 @@ describe('wiring', () => { // The artifact-level sibling needs a full-repo build and therefore runs on // the publish path and nightly. This one reads config text, so it can run // where the defect is authored. - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - expect(ci).toContain('run: pnpm check:published-tsconfig-exclude'); + // objectstack's root ci.yml: `type-check-ui` is objectui's `Type Check` + // (objectstack#25697) and runs on every pull request; the ci.yml under + // `ui/.github/` runs nowhere (objectstack#25712). + expect(jobBlock(rootCi(), UI_JOBS.typeCheck)).toContain(`run: ${uiRun('check:published-tsconfig-exclude')}`); }); - it('leaves the artifact-level gate in place as the second line of defence', () => { + it('keeps the artifact-level gate as the second line of defence', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(manifest.scripts['check:published-dist']).toBe('node scripts/check-published-dist-tooling.mjs'); - const publishGate = fs.readFileSync(path.join(repoRoot, '.github/workflows/published-dist-gate.yml'), 'utf8'); - expect(publishGate).toContain('check:published-dist'); + // The workflow half retired with objectstack#25712. objectui's + // `published-dist-gate.yml` ran it on push and schedule; that file is row 23 + // of the objectstack#25697 workflow roster, "kept, follow-up port", and no + // root workflow runs `check:published-dist` until the port lands. The port + // brings the workflow assertion back against the root file. }); it('is GREEN on this repository — the ratchet landed with its red set converted', () => { diff --git a/ui/scripts/__tests__/check-test-shard-results.test.ts b/ui/scripts/__tests__/check-test-shard-results.test.ts index 8f40b440e2f..3e354bb5ad7 100644 --- a/ui/scripts/__tests__/check-test-shard-results.test.ts +++ b/ui/scripts/__tests__/check-test-shard-results.test.ts @@ -28,7 +28,6 @@ import { shardContexts, unconcludedShards, } from '../check-test-shard-results.mjs'; -import { checkNames, readWorkflows } from './workflow-checks.js'; /** * objectui#9499 — the aggregator that replaced four required shard contexts. @@ -56,7 +55,6 @@ import { checkNames, readWorkflows } from './workflow-checks.js'; */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const ciYaml = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); /** A jobs-API answer: every shard green, plus the aggregator's own in-flight job. */ function greenRun(shards = 8) { @@ -71,22 +69,13 @@ function greenRun(shards = 8) { const read = (jobs: unknown[], shards = 8) => evaluate({ jobs, shards, selfJobName: 'Test' }); describe('check-test-shard-results — the shard names it expects', () => { - it('spells them the way ci.yml names the matrix job', () => { - // Both sides derived, neither retyped: `checkNames` is the same parser - // `dependabot-merge-gate.test.ts` partitions the produced check set with, - // so this compares the gate's expectation against the workflow itself. - const workflow = readWorkflows().find((w) => w.file === 'ci.yml'); - expect(workflow, 'ci.yml must still be a workflow').toBeTruthy(); - - const produced = checkNames(workflow!); - const shards = produced.filter((name) => /^Test \(shard \d+\/\d+\)$/.test(name)); - expect(shards.length, 'ci.yml no longer produces a `Test (shard N/M)` matrix').toBeGreaterThan(1); - - expect( - shardContexts(shards.length), - "the gate's shard-name spelling has drifted from ci.yml's `name:` template", - ).toEqual(shards); - }); + // `spells them the way ci.yml names the matrix job` retired with + // objectstack#25712. It held this gate's `Test (shard N/M)` spelling to + // objectui ci.yml's `test` matrix, a file nothing runs under the monorepo; + // the matrix that runs is the root's `test-ui`, named `Test UI (N/16)`, and + // no root job runs this gate. Both wait on row 7 `Test (the shard + // aggregate)` of the objectstack#25697 workflow roster ("dropped until + // cutover"); its port re-derives the spelling from the root job. it('refuses a width that is not a positive integer', () => { for (const bad of [0, -1, 1.5, Number.NaN]) expect(() => shardContexts(bad)).toThrow(/positive integer/); @@ -1087,80 +1076,16 @@ describe('check-test-shard-results — the check-run reader is walked on every r }); }); -describe('ci.yml wires the gate to its own matrix (#9499)', () => { - /** The `test-aggregate` job block, comments stripped. */ - const aggregate = (() => { - const body = ciYaml.slice(ciYaml.search(/^jobs:[ \t]*$/m)); - const at = body.search(/^ {2}test-aggregate:[ \t]*$/m); - expect(at, 'ci.yml must still define a `test-aggregate:` job').toBeGreaterThan(-1); - const rest = body.slice(at + 1); - const next = rest.search(/^ {2}\S/m); - return (next === -1 ? rest : rest.slice(0, next)) - .split('\n') - .filter((line) => !/^\s*#/.test(line)) - .join('\n'); - })(); - - it('passes the gate the width ci.yml actually runs', () => { - // The one number this script does not derive. Pinning it here is what keeps - // the matrix and the `--shards` argument from drifting apart silently — - // locally and loudly, which is the difference from the repository-settings - // surface the shard count used to be welded to. - const declared = ciYaml.match(/^ {8}shard: \[([^\]]+)\]/m); - expect(declared, 'ci.yml must still declare a `shard:` matrix').toBeTruthy(); - const width = declared![1].split(',').filter((s) => s.trim()).length; - - const passed = aggregate.match(/check-test-shard-results\.mjs --shards (\d+)/); - expect(passed, '`test-aggregate` must still run the shard-results gate').toBeTruthy(); - expect(Number(passed![1]), 'the --shards argument and ci.yml\'s matrix have drifted apart').toBe(width); - }); - - it('tells the gate the name of the job it is running in', () => { - // The gate refuses any verdict over a job list not containing this name, so - // a wrong value here fails closed (exit 2, red) rather than vacuously green. - const name = aggregate.match(/^ {4}name: (.+)$/m); - expect(name, '`test-aggregate` must declare a `name:`').toBeTruthy(); - expect(aggregate).toContain(`--job-name '${name![1].trim()}'`); - }); - - it('needs every job whose result it speaks for, and reports whatever they did', () => { - expect(aggregate).toMatch(/^ {4}needs: \[test, test-dist-pins\]$/m); - // `always()` — without it a red or skipped shard SKIPS this job, and a - // skipped required context counts as SUCCESS in branch protection. No - // event guard since objectui#12214: on `push` and `schedule` this is the - // full battery's test verdict, read the same way. - expect(aggregate).toMatch(/^ {4}if: always\(\)$/m); - // `actions: read` is what the API read needs; a `permissions:` block sets - // every unlisted scope to `none`, so its absence is a 403 and exit 2. - expect(aggregate).toMatch(/^ {6}actions: read$/m); - }); - - it('keeps the re-read bound inside the job\'s own budget (objectui#10931)', () => { - // The worst case is every re-read spent on a shard that never answers. It - // must leave at least half of `timeout-minutes` for the checkout, the - // reads themselves and the dist-pin step: a job killed by its timeout - // reports `cancelled`, which the merge queue reads as a failure. - const timeout = aggregate.match(/^ {4}timeout-minutes: (\d+)$/m); - expect(timeout, '`test-aggregate` must declare its own `timeout-minutes`').toBeTruthy(); - expect(REREAD_LIMIT).toBeGreaterThan(0); - expect(REREAD_LIMIT * REREAD_WAIT_MS).toBeLessThanOrEqual((Number(timeout![1]) * 60_000) / 2); - }); - - it('keeps the whole gate inside the job, with room for the steps around it (objectui#13064)', () => { - // `GATE_BUDGET_MS` bounds every retry and re-read, and each request is cut - // to what is left of it, so it is the gate step's worst case. After it, - // the report-only headroom step makes one `fetchRunJobs()` read with no - // retry, bounded by `REQUEST_TIMEOUT_MS`. The two minutes are for set-up, - // checkout and post steps (the three runs the card records spent about - // ten seconds on them, read by hand once and not re-derived here). Past - // `timeout-minutes` the job reports `cancelled`, which the merge queue - // reads exactly as it reads a red. - const timeout = aggregate.match(/^ {4}timeout-minutes: (\d+)$/m); - expect(timeout, '`test-aggregate` must declare its own `timeout-minutes`').toBeTruthy(); - const job = Number(timeout![1]) * 60_000; - expect(GATE_BUDGET_MS + REQUEST_TIMEOUT_MS + 2 * 60_000).toBeLessThanOrEqual(job); - }); - +describe('check-test-shard-results — the gate budget it sizes itself against', () => { + // The rest of `ci.yml wires the gate to its own matrix (#9499)` retired with + // objectstack#25712: the width, job name, `needs:`, `if:`, permissions, + // rollup env and `timeout-minutes` it pinned were objectui ci.yml's + // `test-aggregate` job, and that file runs nowhere under the monorepo. The + // job is row 7 `Test (the shard aggregate)` of the objectstack#25697 workflow + // roster, "dropped until cutover", to be ported with objectstack's + // shard-attestation pattern. No root job runs this gate until then; the port + // brings those pins back against its job (`helpers/root-ci.ts`). The case + // below reads only the module's own constants, so it stays. it('sizes the two re-read stretches against the recorded staleness and the gate budget (objectui#13064)', () => { const sleeps = REREAD_LIMIT * REREAD_WAIT_MS + STALE_REREAD_LIMIT * STALE_REREAD_WAIT_MS; // Long enough for the 6 min 22 s run 39286083342 recorded, even starting @@ -1172,15 +1097,4 @@ describe('ci.yml wires the gate to its own matrix (#9499)', () => { // A transient retry's whole stretch fits too, with its requests. expect(TRANSIENT_RETRY_LIMIT * TRANSIENT_RETRY_WAIT_MS + (TRANSIENT_RETRY_LIMIT + 1) * REQUEST_TIMEOUT_MS).toBeLessThan(GATE_BUDGET_MS); }); - - it('hands the gate the matrix rollup under the name it reads (objectui#13064)', () => { - // The gate reads it only to decide whether to wait longer. Pinned so that - // renaming it in ci.yml cannot silently switch the extension off. - expect(aggregate).toMatch(/^ {10}TEST_MATRIX_ROLLUP: \$\{\{ needs\.test\.result \}\}$/m); - }); - - it('asserts the dist-pin job too, so nothing in the lane is left ungated', () => { - expect(aggregate).toContain('needs.test-dist-pins.result'); - expect(aggregate).toMatch(/!= 'success'/); - }); }); diff --git a/ui/scripts/__tests__/check-unreferenced-sources.test.ts b/ui/scripts/__tests__/check-unreferenced-sources.test.ts index 78d5aa2e294..8fbc4895ddf 100644 --- a/ui/scripts/__tests__/check-unreferenced-sources.test.ts +++ b/ui/scripts/__tests__/check-unreferenced-sources.test.ts @@ -14,6 +14,7 @@ import { evaluatePath, readBuildConfig, } from '../check-unreferenced-sources.mjs'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#7515 — a source file nothing reaches must not sit in a published @@ -368,25 +369,25 @@ describe('the gate is wired where the sibling gates are', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record; }; - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + // The workflow GitHub runs: objectstack's root ci.yml, whose `type-check-ui` + // job is objectui's `Type Check` (objectstack#25697). objectui's own ci.yml + // under `ui/.github/` runs nowhere (objectstack#25712). + const job = jobBlock(rootCi(), UI_JOBS.typeCheck); it('package.json exposes it as a named script', () => { expect(pkg.scripts['check:unreferenced-sources']).toBe(`node ${GATE}`); }); it('ci.yml runs it after the install it needs (it parses with typescript)', () => { - const install = ci.indexOf('pnpm install --frozen-lockfile'); - const step = ci.indexOf('run: pnpm check:unreferenced-sources'); - expect(step, 'ci.yml does not run `pnpm check:unreferenced-sources`').toBeGreaterThan(-1); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const step = job.indexOf(`run: ${uiRun('check:unreferenced-sources')}`); + expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:unreferenced-sources`').toBeGreaterThan(-1); + expect(install, '`type-check-ui` no longer installs dependencies').toBeGreaterThan(-1); expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); }); - it('runs in the `type-check` job, where the other parse-based gates run', () => { - const jobs = ci.slice(ci.search(/^jobs:[ \t]*$/m)); - const typeCheck = jobs.slice(jobs.search(/^ {2}type-check:[ \t]*$/m)); - const nextJob = typeCheck.slice(1).search(/^ {2}\S/m); - const block = nextJob === -1 ? typeCheck : typeCheck.slice(0, nextJob + 1); - expect(block).toContain('run: pnpm check:unreferenced-sources'); + it('runs in the `type-check-ui` job, where the other parse-based gates run', () => { + expect(job).toContain(`run: ${uiRun('check:unreferenced-sources')}`); }); it("shares the sibling gate's parser rather than growing a second one", () => { diff --git a/ui/scripts/__tests__/coverage-shard-reporter-readability.test.ts b/ui/scripts/__tests__/coverage-shard-reporter-readability.test.ts deleted file mode 100644 index 09a3718983f..00000000000 --- a/ui/scripts/__tests__/coverage-shard-reporter-readability.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * The pin on the coverage shard leg's reporter set (objectui#9177). - * - * ## What this guards, and why nothing else could - * - * `ci.yml`'s `test-coverage` job runs vitest as a sharded matrix and merges - * every shard's blob report in the job below it, which is where the configured - * coverage thresholds are enforced. A CLI `--reporter` **replaces** the - * reporter set rather than adding to it, and vitest's resolution is: - * - * if (!resolved.reporters.length) { - * resolved.reporters.push([isAgent ? "agent" : "default", {}]); - * if (process.env.GITHUB_ACTIONS === "true") - * resolved.reporters.push(["github-actions", {}]); - * } - * - * so `--reporter=blob` on its own displaced BOTH halves of that pair. A red - * shard's log then ended at `blob report written to …` — no failing test name, - * no assertion text, no timeout message — and the shard job's only annotation - * was the generic `Process completed with exit code 1.` The failures survived - * only inside the `coverage-blob-N` artifact, which is download-only. - * - * objectui#8545 measured what that costs: two episodes in which one test file - * held `main`'s coverage gate unevaluated for **84** and **87** consecutive - * pushes, both found the same way — a person reading a job log by hand, days - * later. - * - * Nothing else in this repository reads what that step runs. - * `ci-cd-pipeline-doc.test.ts` pins the pairing between `ci.yml` and the - * pipeline page, but it compares COMMANDS, not flags — its extractor is - * - * for (const m of text.matchAll(/\bpnpm\s+([\w:.-]+)/g)) { - * if (rootScripts.has(m[1])) found.add(`pnpm ${m[1]}`); - * } - * - * whose capture stops at the first space, so `pnpm test:coverage` is all it - * ever sees. Every flag on that line could be deleted and that file stays - * green. - * - * ## Both directions, because the failure has two shapes - * - * Dropping `--reporter=default` / `--reporter=github-actions` restores - * objectui#9177. Dropping `--reporter=blob` to "simplify" the line is the same - * defect wearing the other hat: the merge job loses the only input it has, so - * a readable failure is bought with an unevaluated coverage floor - * (objectui#5403). Neither is caught by CI going green, because the shard leg - * passing is exactly the state in which nobody looks at its log. - * - * ⛔ If you are deleting this file, you are deleting the only mechanical guard - * on that reporter set. The measurement behind it is in the ⚠️ comment above - * the step in `ci.yml`; re-run it before you change the line. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const workflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); -const workflowSource = fs.readFileSync(workflowPath, 'utf8'); - -/** The reporter the merge job needs, and the two that keep the log readable. */ -const BLOB_REPORTER = '--reporter=blob'; -const READABLE_REPORTERS = ['--reporter=default', '--reporter=github-actions']; - -interface Step { - name?: string; - run?: unknown; -} - -/** - * Every `run:` body in the `test-coverage` job that invokes the coverage suite. - * - * Read through the YAML parser rather than off the raw text: the step is a - * folded block scalar (`run: >-`), so the flags are spread over four source - * lines and a regex over the file would have to re-implement the folding to see - * the command the runner actually executes. Matching on `test:coverage` rather - * than on the full string is what makes a rewrite FAIL here instead of - * vanishing — a pin that searched for today's exact line would simply stop - * finding anything and report a healthy green over a step that no longer - * contains it. - */ -function coverageRunSteps(source: string): Array<{ step: string; run: string }> { - const workflow = parseYaml(source) as { - jobs?: Record; - }; - const job = workflow.jobs?.['test-coverage']; - expect( - job, - 'ci.yml no longer defines a `test-coverage` job. If the coverage lane was restructured, this pin ' + - 'needs a new referent — do not delete it without giving the reporter set one.', - ).toBeDefined(); - const found: Array<{ step: string; run: string }> = []; - for (const step of job?.steps ?? []) { - if (typeof step.run !== 'string') continue; - if (!step.run.includes('test:coverage')) continue; - found.push({ step: step.name ?? '(unnamed)', run: step.run }); - } - return found; -} - -describe("ci.yml's coverage shards keep a red run readable", () => { - it('still runs the coverage suite at all — the pin is not vacuous', () => { - // Without this, every assertion below passes over a job that stopped running - // vitest, which is the one way a `toContain` pin lies. - const steps = coverageRunSteps(workflowSource); - expect( - steps.map((s) => s.step), - 'no step in ci.yml\'s `test-coverage` job runs `pnpm test:coverage` any more. If that is ' + - 'deliberate, this pin is obsolete and should be deleted WITH the reasoning that made it ' + - 'obsolete written down. If it is not, the push lane just lost its coverage measurement.', - ).not.toEqual([]); - }); - - it('is reading the shard leg, not some other coverage invocation', () => { - // Names the population the two assertions below judge: the sharded legs. - // `--merge-reports` in the job below is a different step in a different job - // and is deliberately out of reach here. - const sharded = coverageRunSteps(workflowSource).filter((s) => s.run.includes('--shard=')); - expect( - sharded.map((s) => s.step), - 'the `test-coverage` job no longer passes `--shard=` to the coverage suite. The reporter ' + - 'rule below is about the SHARDED legs specifically — an unsharded lane has no blob to ' + - 'keep and a different set of reporters is right for it.', - ).not.toEqual([]); - }); - - it('passes a reporter that prints failing test names alongside the blob', () => { - const offenders = coverageRunSteps(workflowSource) - .filter((s) => s.run.includes(BLOB_REPORTER)) - .flatMap((s) => - READABLE_REPORTERS.filter((r) => !s.run.includes(r)).map((r) => `${s.step}: missing ${r}`), - ); - - expect( - offenders, - 'a coverage shard passes `--reporter=blob` without the reporters that keep its log readable.\n' + - offenders.map((o) => ` - ${o}`).join('\n') + - '\n\nA CLI `--reporter` REPLACES the default set rather than adding to it, so blob alone ends a ' + - 'failing shard at `blob report written to …` with no test name and no assertion text, and leaves ' + - 'the download-only artifact as the only copy of the failure. `--reporter=default` restores the ' + - 'job log; `--reporter=github-actions` restores the per-test `::error` annotation, which is the ' + - 'only form an API reader can get without downloading anything. objectui#9177; the cost is ' + - 'objectui#8545, where one test file held the coverage gate unevaluated for 84 and 87 pushes.', - ).toEqual([]); - }); - - it('keeps the blob reporter the merge job depends on', () => { - const offenders = coverageRunSteps(workflowSource) - .filter((s) => s.run.includes('--shard=')) - .filter((s) => !s.run.includes(BLOB_REPORTER)) - .map((s) => s.step); - - expect( - offenders, - 'a sharded coverage leg no longer writes a blob report:\n' + - offenders.map((o) => ` - ${o}`).join('\n') + - '\n\nThe `coverage-report` job merges every shard\'s blob and enforces the configured thresholds over ' + - 'the merged report — that is the whole reason the lane is sharded (objectui#5403). Restoring log ' + - 'readability by dropping the blob would trade an unreadable failure for an unevaluated coverage ' + - 'floor, which is the same defect wearing the other hat (objectui#9177).', - ).toEqual([]); - }); -}); diff --git a/ui/scripts/__tests__/coverage-shard-width.test.ts b/ui/scripts/__tests__/coverage-shard-width.test.ts deleted file mode 100644 index d7119717835..00000000000 --- a/ui/scripts/__tests__/coverage-shard-width.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * The coverage lane's width, held to ONE number (objectui#12256, folded into - * objectui#12208). - * - * `ci.yml`'s coverage lane spells its shard count in two jobs. `test-coverage` - * declares the matrix and repeats the count in its job name, its step name and - * its `--shard=N/M` flag; `coverage-report` counts the blob reports the shards - * wrote and refuses to merge a partial set. The blob's FILE NAME carries the - * count too (`blob-N-M.json`, written by vitest from the `--shard` flag), so a - * width moved in the matrix alone leaves the merge looking for blobs that no - * shard writes: the count step answers zero, the merge never runs, and the - * coverage gate is unevaluated on every push -- the lane's own worst failure, - * one edit away. Nothing about that is visible on a pull request, because the - * lane runs on `push` and `schedule` only. - * - * So every spelling is read here and held to the matrix: - * - * - the matrix is the contiguous run 1..M; - * - the job name, the step name and `--shard=` all say M; - * - `coverage-report` declares `COVERAGE_SHARDS: 'M'`, and its count step - * reads THAT variable for both the blob glob and the expected count, with - * no literal width left in the step; - * - the merge step is a `--merge-reports` that overrides no threshold, so - * the configured `coverage.thresholds` are what is enforced over the merge - * of all M blobs -- the one place the gate runs, since the shard legs - * zero the thresholds. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const workflowSource = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - -interface Step { - name?: string; - run?: unknown; - id?: string; -} -interface Job { - name?: string; - env?: Record; - strategy?: { matrix?: { shard?: unknown } }; - steps?: Step[]; -} - -const workflow = parseYaml(workflowSource) as { jobs?: Record }; - -function job(key: string): Job { - const found = workflow.jobs?.[key]; - expect(found, `ci.yml no longer defines a \`${key}\` job; this pin needs a new referent, not deletion`).toBeDefined(); - return found as Job; -} - -/** The matrix width, after checking the matrix is the contiguous run 1..M. */ -function matrixWidth(): number { - const shards = job('test-coverage').strategy?.matrix?.shard; - expect(Array.isArray(shards), '`test-coverage` must declare a `shard:` matrix list').toBe(true); - const list = shards as unknown[]; - expect(list.length, 'a zero-wide matrix is a broken reader, not a lane').toBeGreaterThan(0); - expect(list, 'the coverage matrix must be the contiguous run 1..M').toEqual(list.map((_, i) => i + 1)); - return list.length; -} - -function runOf(step: Step | undefined): string { - return typeof step?.run === 'string' ? step.run : ''; -} - -describe("ci.yml's coverage lane says its width once (objectui#12256)", () => { - const width = matrixWidth(); - - it('names each shard job with the matrix width', () => { - expect(job('test-coverage').name).toBe(`Test (coverage shard \${{ matrix.shard }}/${width})`); - }); - - it('passes the matrix width to vitest, in the flag and in the step name', () => { - const steps = (job('test-coverage').steps ?? []).filter((s) => runOf(s).includes('test:coverage')); - expect(steps.length, 'no step in `test-coverage` runs `pnpm test:coverage` any more').toBeGreaterThan(0); - for (const step of steps) { - const flags = [...runOf(step).matchAll(/--shard=\$\{\{ matrix\.shard \}\}\/(\d+)/g)].map((m) => Number(m[1])); - expect(flags, `${step.name}: one --shard flag, carrying the matrix width`).toEqual([width]); - expect(step.name, `${step.name}: the step name states a different width`).toContain(`/${width})`); - } - }); - - it('declares the same width as the count the merge refuses to go below', () => { - expect(job('coverage-report').env?.COVERAGE_SHARDS, '`coverage-report` must declare `COVERAGE_SHARDS`').toBe( - String(width), - ); - }); - - it('counts the blobs through that variable, with no literal width left in the step', () => { - const count = runOf((job('coverage-report').steps ?? []).find((s) => s.id === 'blobs')); - expect(count, '`coverage-report` must keep its `blobs` count step').not.toBe(''); - expect(count).toContain('-name "blob-*-${COVERAGE_SHARDS}.json"'); - expect(count).toContain('-ne "$COVERAGE_SHARDS"'); - // A digit in the blob glob or the comparison is a second spelling of the - // width, and the one that goes stale first. - expect(count).not.toMatch(/blob-\*-\d/); - expect(count).not.toMatch(/-ne\s+\d/); - }); - - it('enforces the configured thresholds over the merge of every blob, overriding none', () => { - const merge = runOf((job('coverage-report').steps ?? []).find((s) => s.id === 'merge')); - expect(merge, '`coverage-report` must keep its `merge` step').toContain('--merge-reports'); - expect(merge, 'the merge is where the thresholds are enforced; it must not override them').not.toContain( - 'thresholds', - ); - const legs = (job('test-coverage').steps ?? []).filter((s) => runOf(s).includes('--shard=')); - for (const leg of legs) { - // The other half of the same contract: the legs zero the thresholds, so - // the merge above is the only evaluation, over all of them. - expect(runOf(leg)).toContain('--coverage.thresholds.lines=0'); - } - }); -}); diff --git a/ui/scripts/__tests__/docs-build-trigger.test.ts b/ui/scripts/__tests__/docs-build-trigger.test.ts deleted file mode 100644 index 2d8c0b5b578..00000000000 --- a/ui/scripts/__tests__/docs-build-trigger.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * objectui#8647 — `Build Docs` must not report green having built nothing. - * - * ## The defect - * - * `ci.yml`'s `docs` job decides in one step whether to build the site, and until - * this pin the decision read a diff against `apps/site/` and `content/` only. - * Those are the site's OUTPUT surface. Its INPUT surface is far larger: - * `turbo run build --filter='@object-ui/site'` builds every workspace package - * the site depends on before `next build` runs at all. So a pull request that - * touched only `packages/**` skipped the build and still reported `success` — - * measured at 13 seconds against a 3m16s uncached build. A skipped build and a - * passed build are the same green to every reader downstream, and two cards had - * already leaned on that green as their Turbopack evidence. - * - * ## ⭐ What this file pins, and why it executes the real shell - * - * The thing that must never regress is a DECISION, not a string. A test that - * only grepped the pathspec out of the YAML would pass while the decision was - * broken by anything else in the step — a reordered early return, a quoting - * slip, a `--` in the wrong place. So the assertions below extract the step's - * `run:` script verbatim, substitute the three `${…}` expressions GitHub would - * substitute, and execute it with `bash -e` (GitHub's own default shell for - * `run:` on Linux) against throwaway git repositories. What is asserted is the - * `should_run` value the step actually writes to `GITHUB_OUTPUT`. - * - * ⭐ That is also what makes this pin able to tell "built and passed" from - * "skipped", which the check itself cannot: `should_run` is the switch every - * later step in the job is gated on, so `should_run=true` is exactly the - * condition under which the site is compiled. - * - * ## The two drift guards, and why the list in the YAML is derived - * - * A hand-curated include list decays silently in the one direction this whole - * card is about: add a dependency to the site, forget the workflow, and the gate - * quietly narrows again with nothing red anywhere. So the list is derived, and - * these tests re-derive it from the two sources of truth on every pull request: - * - * 1. the package-manifest graph reached from `@object-ui/site` over - * `workspace:` dependencies, devDependencies and peerDependencies — the - * graph turbo walks for `^build`. Validated against turbo itself while this - * landed: `turbo run build --filter='@object-ui/site' --dry=json` reported - * 31 tasks, being these 30 packages plus the site. - * 2. the root-level inputs `turbo.json` declares for the `build` task - * (`$TURBO_ROOT$/…`), which are by turbo's own definition files a build's - * output depends on. - * - * Each is checked BEHAVIOURALLY — a fixture file under the package or at the - * declared path must make the real step answer `true` — so a covering entry that - * is misspelled, wrongly quoted or shadowed fails here rather than in production. - * - * ## The negative set is derived too - * - * Widening a gate to "every pull request" is the other way to get this wrong, - * and it was ruled out on cost: the job is a Turbopack build. The packages - * OUTSIDE the closure must still skip, and that set is computed as - * `all workspace packages − closure` rather than listed, so a package that later - * joins the closure moves between the two sets on its own instead of leaving a - * stale expectation behind. - * - * ⛔ `Build Docs` is a required status check on this repository's merge queue. - * Renaming the job, or letting it stop reporting a conclusion, does not make the - * gate stricter — it makes the repository unmergeable. The job name is pinned - * below for that reason, and every remedy here keeps the job reporting on every - * pull request exactly as it does today. - */ - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const ciWorkflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); -const SITE_PACKAGE = '@object-ui/site'; -const SITE_DIR = 'apps/site'; -const JOB_ID = 'docs'; -const JOB_NAME = 'Build Docs'; -const STEP_ID = 'docs-changes'; -const WORKSPACE_ROOTS = ['packages', 'apps', 'examples']; - -// ── Reading the workflow ──────────────────────────────────────────────────── - -type WorkflowStep = { id?: string; name?: string; run?: string; if?: string; uses?: string }; - -const ciWorkflow = parseYaml(fs.readFileSync(ciWorkflowPath, 'utf8')) as { - jobs: Record; -}; - -const docsJob = ciWorkflow.jobs?.[JOB_ID]; -const docsSteps: WorkflowStep[] = docsJob?.steps ?? []; -const filterStep = docsSteps.find((s) => s.id === STEP_ID); - -/** - * The expressions the harness substitutes. Pinned as an exact set: a new `${…}` - * in this step would be left unsubstituted by the harness, and bash would read - * whatever the literal text happens to mean — a silently unfaithful test. - */ -const SUBSTITUTED_EXPRESSIONS = [ - 'github.event_name', - 'github.event.pull_request.base.sha', - 'github.event.pull_request.head.sha', -] as const; - -function expressionsIn(script: string): string[] { - return [...script.matchAll(/\$\{\{\s*(.+?)\s*\}\}/g)].map((m) => m[1]); -} - -// ── Executing the real step ───────────────────────────────────────────────── - -function git(cwd: string, ...args: string[]): string { - return execFileSync('git', args, { - cwd, - encoding: 'utf8', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'pin', - GIT_AUTHOR_EMAIL: 'pin@example.invalid', - GIT_COMMITTER_NAME: 'pin', - GIT_COMMITTER_EMAIL: 'pin@example.invalid', - GIT_CONFIG_GLOBAL: '/dev/null', - GIT_CONFIG_SYSTEM: '/dev/null', - }, - }).trim(); -} - -function writeFixtureFiles(dir: string, files: string[]): void { - for (const rel of files) { - const abs = path.join(dir, rel); - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, `// fixture for the docs-build trigger pin\n`); - } -} - -/** - * A throwaway repository whose first commit is the base and whose every later - * commit sits directly on that base, touching one fixture's files and nothing - * else. `A...B` therefore has `A` as its merge base, exactly as it does for a - * pull request. - */ -function buildFixtureRepo(fixtures: Record): { - dir: string; - base: string; - heads: Record; -} { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-build-trigger-')); - git(dir, 'init', '--quiet', '--initial-branch=main'); - writeFixtureFiles(dir, ['seed.txt']); - git(dir, 'add', '-A'); - git(dir, 'commit', '--quiet', '-m', 'base'); - const base = git(dir, 'rev-parse', 'HEAD'); - - const heads: Record = {}; - for (const [name, files] of Object.entries(fixtures)) { - git(dir, 'checkout', '--quiet', '--detach', base); - git(dir, 'clean', '-qfd'); - writeFixtureFiles(dir, files); - git(dir, 'add', '-A'); - git(dir, 'commit', '--quiet', '-m', `fixture: ${name}`); - heads[name] = git(dir, 'rev-parse', 'HEAD'); - } - git(dir, 'checkout', '--quiet', '--detach', base); - return { dir, base, heads }; -} - -function runFilterStep(options: { - cwd: string; - eventName?: string; - baseSha: string; - headSha: string; -}): { shouldRun: string | null; log: string } { - const script = filterStep?.run; - if (!script) throw new Error(`step \`${STEP_ID}\` has no run: script`); - const rendered = script - .replaceAll(/\$\{\{\s*github\.event_name\s*\}\}/g, options.eventName ?? 'pull_request') - .replaceAll(/\$\{\{\s*github\.event\.pull_request\.base\.sha\s*\}\}/g, options.baseSha) - .replaceAll(/\$\{\{\s*github\.event\.pull_request\.head\.sha\s*\}\}/g, options.headSha); - - const outFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'docs-build-out-')), 'out'); - fs.writeFileSync(outFile, ''); - const scriptFile = path.join(path.dirname(outFile), 'step.sh'); - fs.writeFileSync(scriptFile, rendered); - - // `bash -e {0}` is GitHub's default shell for `run:` on a Linux runner. - const log = execFileSync('bash', ['-e', scriptFile], { - cwd: options.cwd, - encoding: 'utf8', - env: { ...process.env, GITHUB_OUTPUT: outFile }, - }); - - const written = fs.readFileSync(outFile, 'utf8'); - const match = [...written.matchAll(/^should_run=(\S+)$/gm)]; - return { shouldRun: match.length ? match[match.length - 1][1] : null, log }; -} - -// ── Deriving what the site build consumes ─────────────────────────────────── - -type Manifest = { - name?: string; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; -}; - -function readWorkspaceManifests(): Map { - const byName = new Map(); - for (const root of WORKSPACE_ROOTS) { - const abs = path.join(repoRoot, root); - if (!fs.existsSync(abs)) continue; - for (const entry of fs.readdirSync(abs)) { - const manifestPath = path.join(abs, entry, 'package.json'); - if (!fs.existsSync(manifestPath)) continue; - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest; - if (manifest.name) byName.set(manifest.name, { dir: `${root}/${entry}`, manifest }); - } - } - return byName; -} - -const workspaceManifests = readWorkspaceManifests(); - -/** - * The workspace packages turbo builds on the way to the site. `^build` follows - * the internal package graph, so every `workspace:` dependency edge counts — - * dev and peer edges included, because they are edges in that same graph. - */ -function siteBuildClosure(): string[] { - const seen = new Set(); - const stack = [SITE_PACKAGE]; - while (stack.length) { - const name = stack.pop()!; - if (seen.has(name)) continue; - seen.add(name); - const entry = workspaceManifests.get(name); - if (!entry) continue; - const { dependencies, devDependencies, peerDependencies, optionalDependencies } = entry.manifest; - for (const group of [dependencies, devDependencies, peerDependencies, optionalDependencies]) { - for (const [dep, range] of Object.entries(group ?? {})) { - if (String(range).startsWith('workspace:') && !seen.has(dep)) stack.push(dep); - } - } - } - seen.delete(SITE_PACKAGE); - return [...seen] - .map((name) => workspaceManifests.get(name)?.dir) - .filter((dir): dir is string => Boolean(dir)) - .sort(); -} - -const closureDirs = siteBuildClosure(); -const allWorkspaceDirs = [...workspaceManifests.values()].map((v) => v.dir).sort(); -const outsideClosureDirs = allWorkspaceDirs.filter( - (dir) => dir !== SITE_DIR && !closureDirs.includes(dir), -); - -/** Root-level build inputs `turbo.json` declares for the `build` task. */ -function turboRootBuildInputs(): string[] { - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - tasks?: Record; - }; - const inputs = turbo.tasks?.build?.inputs ?? []; - return inputs - .filter((i) => i.startsWith('$TURBO_ROOT$/')) - .map((i) => i.slice('$TURBO_ROOT$/'.length)) - .sort(); -} - -/** Expand a declared input to a file that exists today, so the fixture is real. */ -function resolveDeclaredInput(declared: string): string { - if (!declared.includes('*')) return declared; - const dir = path.dirname(declared); - const pattern = new RegExp(`^${path.basename(declared).replaceAll('.', '\\.').replaceAll('*', '.*')}$`); - const matches = fs - .readdirSync(path.join(repoRoot, dir)) - .filter((f) => pattern.test(f)) - .sort(); - if (!matches.length) throw new Error(`turbo declares \`${declared}\` but nothing matches it`); - return `${dir}/${matches[0]}`; -} - -const MANIFEST_INPUTS = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'turbo.json']; - -// ── The fixtures, built once ──────────────────────────────────────────────── - -const fixtures: Record = { - // The shape the card was filed on: a pull request that changed only - // `packages/types/**` and reported `success` in 13 seconds having built - // nothing. This is the firing control — it must now build. - 'packages/types only': ['packages/types/src/index.ts'], - 'content only': ['content/docs/guide/example.mdx'], - 'site only': [`${SITE_DIR}/app/page.tsx`], - 'root readme only': ['README.md'], - 'changeset only': ['.changeset/lovely-pandas-clap.md'], - 'internal docs only': ['docs/adr/9999-example.md'], - 'unrelated workflow only': ['.github/workflows/nothing-to-do-with-the-site.yml'], - 'ci.yml only': ['.github/workflows/ci.yml'], -}; -for (const dir of closureDirs) fixtures[`closure:${dir}`] = [`${dir}/src/pin-fixture.ts`]; -for (const dir of outsideClosureDirs) fixtures[`outside:${dir}`] = [`${dir}/src/pin-fixture.ts`]; -for (const declared of turboRootBuildInputs()) { - fixtures[`turbo-input:${declared}`] = [resolveDeclaredInput(declared)]; -} -for (const manifest of MANIFEST_INPUTS) fixtures[`manifest:${manifest}`] = [manifest]; - -const repo = buildFixtureRepo(fixtures); - -function decide(fixtureName: string): string | null { - return runFilterStep({ - cwd: repo.dir, - baseSha: repo.base, - headSha: repo.heads[fixtureName], - }).shouldRun; -} - -// ── Assertions ────────────────────────────────────────────────────────────── - -describe('Build Docs — the job keeps reporting, under the name the queue requires', () => { - it('is still called `Build Docs`', () => { - expect( - docsJob?.name, - '`Build Docs` is a required status check on the merge-queue ruleset; a required context that stops reporting under its own name makes the repository unmergeable, which is not a stricter gate', - ).toBe(JOB_NAME); - }); - - it('has no path filter on the job or the workflow triggers that could keep it from reporting', () => { - const triggers = (ciWorkflow as unknown as { on?: Record }).on ?? {}; - const pullRequest = (triggers as { pull_request?: Record }).pull_request ?? {}; - expect( - Object.keys(pullRequest), - 'the filter belongs in the job, not on the trigger: a `paths`/`paths-ignore` here would make the required context absent rather than green', - ).not.toContain('paths'); - expect(Object.keys(pullRequest)).not.toContain('paths-ignore'); - }); - - it('gates every later step of the job on the one decision this file pins', () => { - const guarded = docsSteps.filter((s) => s.if?.includes(`steps.${STEP_ID}.outputs.should_run`)); - expect(guarded.length).toBeGreaterThanOrEqual(8); - expect( - guarded.map((s) => s.name), - 'the site build must be one of the guarded steps, or `should_run` no longer means "the site was built"', - ).toContain('Build Site'); - }); -}); - -describe('the decision step is executed as written, not paraphrased', () => { - it('exists and substitutes exactly the expressions the harness knows about', () => { - expect(filterStep, `\`ci.yml\` must still have a step with id \`${STEP_ID}\``).toBeTruthy(); - expect([...new Set(expressionsIn(filterStep!.run ?? ''))].sort()).toEqual( - [...SUBSTITUTED_EXPRESSIONS].sort(), - ); - }); -}); - -describe('the firing control — the shape that used to report green without building', () => { - it('builds on a pull request that touches only `packages/types`', () => { - expect( - decide('packages/types only'), - 'this is the whole card: `packages/types`-only pull requests reported success having built nothing', - ).toBe('true'); - }); - - it('still builds for the two surfaces the old filter knew about', () => { - expect(decide('content only')).toBe('true'); - expect(decide('site only')).toBe('true'); - }); - - it('builds for a change to the workflow that defines the build', () => { - // The gate validates its own edits. Without this a pull request rewriting - // this very job would take the 13-second green as its evidence — which is - // the failure this card is about, applied to the fix for it. - expect(decide('ci.yml only')).toBe('true'); - expect( - decide('unrelated workflow only'), - 'only this workflow, not `.github/` wholesale', - ).toBe('false'); - }); -}); - -describe('drift guard 1 — every package turbo builds for the site triggers the build', () => { - it('derives a non-trivial closure', () => { - expect(closureDirs.length).toBeGreaterThan(20); - expect(closureDirs).toContain('packages/types'); - expect(closureDirs).toContain('packages/react'); - }); - - it.each(closureDirs)('%s', (dir) => { - expect( - decide(`closure:${dir}`), - `\`${dir}\` is in the site's build closure, so a change to it changes what this job compiles — add it to the pathspec in the \`${STEP_ID}\` step`, - ).toBe('true'); - }); -}); - -describe('drift guard 2 — every root build input turbo declares triggers the build', () => { - const declared = turboRootBuildInputs(); - - it('reads a non-empty declaration out of turbo.json', () => { - expect(declared.length).toBeGreaterThan(0); - }); - - it.each(declared)('%s', (input) => { - expect( - decide(`turbo-input:${input}`), - `turbo.json declares \`${input}\` as an input to every \`build\` task — add it to the pathspec in the \`${STEP_ID}\` step`, - ).toBe('true'); - }); - - it.each(MANIFEST_INPUTS)('%s', (manifest) => { - expect( - decide(`manifest:${manifest}`), - `\`${manifest}\` decides which versions the build resolves`, - ).toBe('true'); - }); -}); - -describe('the gate is still a filter — it did not become "every pull request"', () => { - it('derives a non-empty set of packages outside the closure', () => { - expect(outsideClosureDirs.length).toBeGreaterThan(0); - }); - - it.each(outsideClosureDirs)('skips for %s', (dir) => { - expect( - decide(`outside:${dir}`), - `\`${dir}\` is not in the site's build closure, so building the site for it buys nothing; if it has just become a dependency, this file's closure derivation will have moved it to the other set`, - ).toBe('false'); - }); - - it.each(['root readme only', 'changeset only', 'internal docs only', 'unrelated workflow only'])( - 'skips for %s', - (name) => { - expect(decide(name)).toBe('false'); - }, - ); -}); - -describe('the two silent-skip guards this card must not undo', () => { - it('builds unconditionally when the event is not a pull request', () => { - // objectui#3523: a merge_group build has no `github.event.pull_request`, so - // the diff would run on a bare `...` range — which git reads as - // `HEAD...HEAD`, exits 0 on, and prints nothing for. Without this early - // return the LAST check before `main` is the one that skips. - const bare = runFilterStep({ - cwd: repo.dir, - eventName: 'merge_group', - baseSha: '', - headSha: '', - }); - expect(bare.shouldRun).toBe('true'); - }); - - it('builds when the diff cannot be computed at all', () => { - // Fails OPEN (objectui#3723): an unreachable base sha must not read as - // "nothing docs-related changed". - const unreachable = runFilterStep({ - cwd: repo.dir, - baseSha: '0000000000000000000000000000000000000000', - headSha: repo.heads['packages/types only'], - }); - expect(unreachable.shouldRun).toBe('true'); - }); -}); diff --git a/ui/scripts/__tests__/ensure-chromium-ready.test.ts b/ui/scripts/__tests__/ensure-chromium-ready.test.ts index 2c586c9a391..d16e7ae9a8c 100644 --- a/ui/scripts/__tests__/ensure-chromium-ready.test.ts +++ b/ui/scripts/__tests__/ensure-chromium-ready.test.ts @@ -5,6 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { jobBlock, rootCi, UI_JOBS } from './helpers/root-ci'; + /** * objectui#5304 — `Build & E2E` hung inside `apt-get update` and was cancelled * at the 30-minute job ceiling, three times, with a byte-identical last line: @@ -238,11 +240,19 @@ describe('ensure-chromium-ready.sh — the bound holds where GNU `timeout` is ab }); describe('the workflows actually use it', () => { - const workflows = ['.github/workflows/ci.yml', '.github/workflows/live-e2e.yml']; + /** + * The job that provisions Chromium: `build-e2e-ui` in objectstack's root + * ci.yml, objectui's `Build & E2E` (objectstack#25697). The ci.yml under + * `ui/.github/` runs nowhere (objectstack#25712). objectui's second caller, + * `live-e2e.yml`, left this list with it: it is row 17 of the + * objectstack#25697 workflow roster, "kept, follow-up port", and comes back + * here with that port. + */ + const workflows = [{ file: 'ci.yml `build-e2e-ui`', read: () => jobBlock(rootCi(), UI_JOBS.buildE2e) }]; /** - * Whole-line comments removed. Required, not cosmetic: both workflows now - * explain the incident in prose and name the very spellings asserted against + * Whole-line comments removed. Required, not cosmetic: objectui's workflows + * explained the incident in prose and named the very spellings asserted against * below, so a scan that counted the prose would report an unbounded apt call * that no step makes. */ @@ -252,8 +262,8 @@ describe('the workflows actually use it', () => { .filter((line) => !/^\s*#/.test(line)) .join('\n'); - it.each(workflows)('%s calls the script instead of shelling straight to apt', (file) => { - const yaml = withoutComments(fs.readFileSync(path.join(repoRoot, file), 'utf8')); + it.each(workflows)('$file calls the script instead of shelling straight to apt', ({ file, read }) => { + const yaml = withoutComments(read()); expect( yaml, diff --git a/ui/scripts/__tests__/helpers/root-ci.ts b/ui/scripts/__tests__/helpers/root-ci.ts new file mode 100644 index 00000000000..eeba4613d51 --- /dev/null +++ b/ui/scripts/__tests__/helpers/root-ci.ts @@ -0,0 +1,112 @@ +/** + * The workflow that runs objectui's gates, read where it lives (objectstack#25712). + * + * Under the objectstack monorepo this tree is `ui/`, and GitHub runs only the + * repository root's `.github/workflows/`. objectui's own `ci.yml` jobs were + * ported there by objectstack#25697 (`Type Check` → `type-check-ui`, `Test` → + * `test-ui`, `Build & E2E` → `build-e2e-ui`, and `lint.yml`'s `Lint` → + * `lint-ui`), and every objectui command in them is spelled + * `pnpm --dir ui run `. A pin in this directory that asks "does CI run + * this gate, and in the right place" asks it of that file, by path, through + * this module. It never reads a copy under `ui/.github/`: nothing runs one. + * + * What the port did not carry (the coverage lane, the docs job, the shard + * aggregate, the affected-test and type-check-scope selectors, the timings + * upload) has no job here, and the pins on those retired with a disposition + * beside them rather than being pointed at a job that does something else. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import { nestedPrefix, workspaceRoot } from '../../workspace-root.mjs'; + +/** The four root jobs objectui's CI became (objectstack#25697). */ +export const UI_JOBS = { + typeCheck: 'type-check-ui', + test: 'test-ui', + lint: 'lint-ui', + buildE2e: 'build-e2e-ui', +} as const; + +/** The workflow GitHub runs: `.github/workflows/ci.yml` at the workspace root. */ +export const rootCiPath = path.join(workspaceRoot(), '.github/workflows/ci.yml'); + +/** That workflow's text. */ +export function rootCi(): string { + return fs.readFileSync(rootCiPath, 'utf8'); +} + +/** How the root workflow spells one of objectui's root scripts. */ +export function uiRun(alias: string): string { + return `pnpm --dir ui run ${alias}`; +} + +/** The text with whole-line YAML comments removed, so prose cannot satisfy a pin. */ +export function withoutComments(yaml: string): string { + return yaml + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); +} + +/** + * One job's text, from its ` :` line up to the next job key at the same + * indent. Throws when the workflow defines no such job, so a renamed job is a + * red pin, never an empty block that contains nothing and asserts nothing. + */ +export function jobBlock(ci: string, key: string): string { + const jobsAt = ci.search(/^jobs:[ \t]*$/m); + if (jobsAt === -1) throw new Error(`${rootCiPath} has no top-level \`jobs:\` key`); + const jobs = ci.slice(jobsAt); + const start = jobs.search(new RegExp(`^ {2}${key.replace(/[-.]/g, '\\$&')}:[ \\t]*$`, 'm')); + if (start === -1) throw new Error(`${rootCiPath} defines no \`${key}\` job`); + const rest = jobs.slice(start + 1); + const next = rest.search(/^ {2}[a-z0-9][a-z0-9_-]*:[ \t]*$/m); + return next === -1 ? jobs.slice(start) : jobs.slice(start, start + 1 + next); +} + +/** Everything above `jobs:`: the triggers, the concurrency group, the defaults. */ +export function triggerBlock(ci: string): string { + const jobsAt = ci.search(/^jobs:[ \t]*$/m); + if (jobsAt === -1) throw new Error(`${rootCiPath} has no top-level \`jobs:\` key`); + return ci.slice(0, jobsAt); +} + +/** + * The `ui:` globs of the `filter` job's paths filter: a change matching one of + * them sets `needs.filter.outputs.ui`, which every `* UI` job's `if:` reads. + * Read as text, the way the filter itself is a YAML string. Throws on an empty + * list, the reading that would make every "is not hidden" pin vacuous. + */ +export function uiFilterGlobs(ci: string): string[] { + const filter = jobBlock(ci, 'filter'); + const lines = filter.split('\n'); + const at = lines.findIndex((line) => /^ +ui:[ \t]*$/.test(line)); + if (at === -1) throw new Error(`${rootCiPath}: the \`filter\` job declares no \`ui:\` paths filter`); + const indent = lines[at].search(/\S/); + const globs: string[] = []; + for (const line of lines.slice(at + 1)) { + if (/^\s*(#.*)?$/.test(line)) continue; + if (line.search(/\S/) <= indent) break; + const m = line.match(/^\s+-\s+'([^']+)'\s*$/); + if (!m) throw new Error(`${rootCiPath}: cannot read the \`ui:\` filter entry ${JSON.stringify(line)}`); + globs.push(m[1]); + } + if (globs.length === 0) throw new Error(`${rootCiPath}: the \`ui:\` paths filter lists no globs`); + return globs; +} + +/** A path relative to this tree, as the root workflow's filters see it (`ui/`). */ +export function repoPath(uiRelative: string): string { + return `${nestedPrefix()}${uiRelative}`; +} + +/** A paths-filter glob against a repository path: `**` crosses separators, `*` does not. */ +export function globMatches(glob: string, file: string): boolean { + return new RegExp( + `^${glob + .split('**') + .map((part) => part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')) + .join('.*')}$`, + ).test(file); +} diff --git a/ui/scripts/__tests__/report-turbo-cache.test.ts b/ui/scripts/__tests__/report-turbo-cache.test.ts index 90a6d53dc14..9fdce593f88 100644 --- a/ui/scripts/__tests__/report-turbo-cache.test.ts +++ b/ui/scripts/__tests__/report-turbo-cache.test.ts @@ -4,8 +4,6 @@ import os from 'node:os'; import path from 'node:path'; import { cacheDirStats, main, newestSummary, pruneCacheDir, render, summarize } from '../report-turbo-cache.mjs'; -import { parse as parseYaml } from 'yaml'; -import { fileURLToPath } from 'node:url'; /** * objectui#12222 — the reader behind `Type Check`'s cache report. @@ -194,156 +192,20 @@ describe('report-turbo-cache (objectui#12222)', () => { }); }); -describe('ci.yml — only the full battery writes the type-check cache, pruned first (objectui#12222)', () => { - const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); - const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8')) as { - jobs: Record }> }>; - }; - const steps = workflow.jobs['type-check'].steps; - const FULL_BATTERY = "(github.event_name == 'push' || github.event_name == 'schedule')"; - - it('saves only on push and schedule, whose run covers every task', () => { - // By path: the job's other save is the pinned @objectstack build's (objectui#12957). - const save = steps.find((s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache'); - expect(save?.if ?? '').toContain(FULL_BATTERY); - }); - - it('prunes before it saves, on the same events, and never on a pull request or a queue build', () => { - const pruneAt = steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs --prune'); - const saveAt = steps.findIndex((s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache'); - expect(pruneAt, 'the prune step is gone').toBeGreaterThan(-1); - expect(pruneAt).toBeLessThan(saveAt); - expect(steps[pruneAt].if ?? '').toContain(FULL_BATTERY); - }); -}); - -/** - * objectui#12276 — every turbo cache in `.github/workflows/` is its job's own. - * - * Until then four of the five jobs that restore `.turbo/cache` ended their - * restore keys with a bare `turbo--`, and three of them (`Lint`, `Bundle - * Analysis`, the release job) saved under one and the same `turbo--` - * key. `actions/cache` takes the newest match on the run's own ref first, so a - * job restored whichever job's cache had reached that ref -- the measured - * instance is objectui#12222's, where `Type Check` restored the `docs` job's - * multi-GB build cache on a pull request -- and saved it again under its own - * key. The `type-check` job's shape (objectui#12222, the block above) is the - * rule here, applied to the population rather than to one job: its own key - * prefix and no other restore key; pull-request and queue runs restore and - * never save; the saving runs prune to what they used first; and every run - * reports what it replayed and which key it restored, which is how "no job - * restores another job's cache" is read after the fact (the notice names the - * restored key) rather than written down here. - */ -describe('every workflow turbo cache: its own key, written by main only, pruned and reported (objectui#12276)', () => { - type Step = { - name?: string; - id?: string; - uses?: string; - run?: string; - if?: string; - env?: Record; - with?: Record; - }; - const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); - const workflowDir = path.join(repoRoot, '.github/workflows'); - const KEY = /^turbo-\$\{\{ runner\.os \}\}-([a-z][a-z0-9-]*)-\$\{\{ github\.sha \}\}$/; - - const caches = fs - .readdirSync(workflowDir) - .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) - .sort() - .flatMap((file) => { - const wf = parseYaml(fs.readFileSync(path.join(workflowDir, file), 'utf8')) as { - on?: Record; - jobs?: Record; - }; - const triggers = Object.keys(wf.on ?? {}); - return Object.entries(wf.jobs ?? {}).flatMap(([job, def]) => { - const steps = def.steps ?? []; - const restoreAt = steps.findIndex( - (s) => (s.uses ?? '').startsWith('actions/cache/restore@') && s.with?.path === '.turbo/cache', - ); - if (restoreAt === -1) return []; - const saveAt = steps.findIndex( - (s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache', - ); - const restore = steps[restoreAt]; - const tag = KEY.exec(restore.with?.key ?? '')?.[1] ?? null; - return [{ where: `${file} \`${job}\``, triggers, steps, restoreAt, saveAt, restore, tag }]; - }); - }); - - /** Whether an `if:` expression names this event. */ - const names = (expr: string | undefined, event: string) => (expr ?? '').includes(`github.event_name == '${event}'`); - - it('reads the population, and the type-check job the rule is taken from is in it', () => { - expect(caches.length, 'the workflow scan found implausibly few turbo caches').toBeGreaterThan(2); - expect(caches.map((c) => c.where)).toContain('ci.yml `type-check`'); - }); - - it('keys each cache with its own prefix, and restores by that prefix alone', () => { - const off = caches.flatMap((c) => { - if (c.tag === null) return [`${c.where}: key \`${c.restore.with?.key}\` is not turbo---`]; - const restoreKeys = (c.restore.with?.['restore-keys'] ?? '').split('\n').map((l) => l.trim()).filter(Boolean); - const own = `turbo-\${{ runner.os }}-${c.tag}-`; - return restoreKeys.length === 1 && restoreKeys[0] === own - ? [] - : [`${c.where}: restore-keys ${JSON.stringify(restoreKeys)}, expected exactly ["${own}"]`]; - }); - expect(off, 'a turbo cache restore can reach another job’s cache:\n' + off.map((o) => ` - ${o}`).join('\n')).toEqual([]); - }); - - it('gives no two jobs a prefix that matches the other’s key', () => { - const tags = caches.map((c) => c.tag).filter((t): t is string => t !== null); - const clashes = tags.flatMap((a, i) => - tags.filter((b, j) => i !== j && `${b}-`.startsWith(`${a}-`)).map((b) => `${a} restores ${b}`), - ); - expect(clashes, 'one job’s restore prefix is a prefix of another job’s key').toEqual([]); - }); - - it('saves only on runs that are not a pull request or a queue build, after pruning on the same runs', () => { - const off = caches.flatMap((c) => { - if (c.saveAt === -1) return [`${c.where}: restores \`.turbo/cache\` and never saves it`]; - const save = c.steps[c.saveAt]; - const pruneAt = c.steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs --prune'); - const out: string[] = []; - if (pruneAt === -1 || pruneAt > c.saveAt) out.push(`${c.where}: no prune step before the save`); - const reviewed = c.triggers.includes('pull_request') || c.triggers.includes('merge_group'); - if (reviewed) { - if (!names(save.if, 'push')) out.push(`${c.where}: the save runs on pull requests or queue builds`); - for (const event of ['push', 'schedule', 'pull_request', 'merge_group']) { - if (pruneAt !== -1 && names(save.if, event) !== names(c.steps[pruneAt].if, event)) { - out.push(`${c.where}: the prune and the save disagree on \`${event}\``); - } - } - } - for (const event of ['pull_request', 'merge_group']) { - if (names(save.if, event)) out.push(`${c.where}: the save names \`${event}\``); - } - return out; - }); - expect(off, off.map((o) => ` - ${o}`).join('\n')).toEqual([]); - }); - - it('reports what it replayed and the key it restored, from a summary its turbo step wrote', () => { - const off = caches.flatMap((c) => { - const reportAt = c.steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs'); - if (reportAt === -1) return [`${c.where}: no \`node scripts/report-turbo-cache.mjs\` step`]; - const report = c.steps[reportAt]; - const out: string[] = []; - if (c.saveAt !== -1 && reportAt > c.saveAt) out.push(`${c.where}: the report runs after the save`); - if (!(report.if ?? '').includes('!cancelled()')) { - out.push(`${c.where}: the report must run on a red run too (\`!cancelled()\`)`); - } - const expected = `\${{ steps.${c.restore.id}.outputs.cache-matched-key }}`; - if (report.env?.RESTORED_CACHE_KEY !== expected) { - out.push(`${c.where}: the report must read RESTORED_CACHE_KEY from ${expected}`); - } - const summarised = c.steps.slice(c.restoreAt + 1, reportAt).some((s) => s.env?.TURBO_RUN_SUMMARY === 'true'); - if (!summarised) out.push(`${c.where}: no step between the restore and the report sets TURBO_RUN_SUMMARY`); - return out; - }); - expect(off, off.map((o) => ` - ${o}`).join('\n')).toEqual([]); - }); -}); +// Two workflow pins retired with objectstack#25712: +// +// - `ci.yml — only the full battery writes the type-check cache, pruned first +// (objectui#12222)`, which held objectui ci.yml's `type-check` job to this +// script's `--prune` before its push/schedule-only `.turbo/cache` save; +// - `every workflow turbo cache: its own key, written by main only, pruned +// and reported (objectui#12276)`, the same rule over every job in +// objectui's `.github/workflows/`. +// +// Neither population runs under the monorepo: GitHub runs only the root +// `.github/workflows/`. The four root `* UI` jobs that replaced objectui's +// (objectstack#25697, row 7 and row 16 of its workflow roster) carry +// objectstack's own Turbo-cache steps instead, on purpose: a +// `-turbo---` key that restores from `build-core`'s cache +// when the job has none, a `Save Turbo cache (main only)` on push, and no +// prune or report step. Running this reporter there is a follow-up port; it +// brings these pins back against those jobs (`helpers/root-ci.ts`). diff --git a/ui/scripts/__tests__/scripts-type-check.test.ts b/ui/scripts/__tests__/scripts-type-check.test.ts index 15e0e3897cf..6efaf00f885 100644 --- a/ui/scripts/__tests__/scripts-type-check.test.ts +++ b/ui/scripts/__tests__/scripts-type-check.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; + /** * objectui#3494 — the pin tests in this directory had no type gate of their own. * @@ -35,7 +37,6 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ const configPath = path.join(repoRoot, 'tsconfig.scripts.json'); const consoleNodeConfigPath = path.join(repoRoot, 'apps/console/tsconfig.node.json'); const scriptsDir = path.join(repoRoot, 'scripts'); -const ciWorkflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); /** The root script CI invokes, and the one a contributor can run locally. */ const SCRIPT_NAME = 'type-check:scripts'; @@ -290,15 +291,14 @@ describe('the gate is actually wired up (objectui#3494)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; - const ciWorkflow = fs.readFileSync(ciWorkflowPath, 'utf8'); - /** The `type-check:` job body, from its key up to the next job at the same indent. */ + /** + * The `type-check-ui:` job body of objectstack's root ci.yml, which is + * objectui's `Type Check` (objectstack#25697). The ci.yml under `ui/.github/` + * runs nowhere (objectstack#25712). + */ function typeCheckJob(): string { - const start = ciWorkflow.search(/^ {2}type-check:[ \t]*$/m); - expect(start, 'ci.yml must still have a `type-check:` job').toBeGreaterThan(-1); - const rest = ciWorkflow.slice(start + 1); - const next = rest.search(/^ {2}[a-z0-9][a-z0-9-]*:[ \t]*$/m); - return next === -1 ? rest : rest.slice(0, next); + return jobBlock(rootCi(), UI_JOBS.typeCheck); } it('is runnable locally, not CI-only', () => { @@ -313,10 +313,10 @@ describe('the gate is actually wired up (objectui#3494)', () => { it('runs in ci.yml’s type-check job', () => { expect( typeCheckJob(), - `ci.yml’s type-check job must run \`pnpm ${SCRIPT_NAME}\`. Without it, tsconfig.scripts.json ` + + `ci.yml’s \`type-check-ui\` job must run \`pnpm --dir ui run ${SCRIPT_NAME}\`. Without it, tsconfig.scripts.json ` + 'is a file that looks like a gate while no CI job reads it — the same shape as the ' + 'unchecked tests it was added to fix.', - ).toContain(`pnpm ${SCRIPT_NAME}`); + ).toContain(uiRun(SCRIPT_NAME)); }); it('runs AFTER dependencies are installed', () => { @@ -325,7 +325,7 @@ describe('the gate is actually wired up (objectui#3494)', () => { // a resolution error that looks nothing like its real cause. const job = typeCheckJob(); const install = job.indexOf('pnpm install --frozen-lockfile'); - const check = job.indexOf(`pnpm ${SCRIPT_NAME}`); + const check = job.indexOf(uiRun(SCRIPT_NAME)); expect(install, 'the type-check job must still install dependencies').toBeGreaterThan(-1); expect(check, 'the type-check job must run the scripts type-check').toBeGreaterThan(-1); diff --git a/ui/scripts/__tests__/select-affected-tests.test.ts b/ui/scripts/__tests__/select-affected-tests.test.ts index 3daacc2dc33..ce6887e98a5 100644 --- a/ui/scripts/__tests__/select-affected-tests.test.ts +++ b/ui/scripts/__tests__/select-affected-tests.test.ts @@ -316,36 +316,14 @@ describe('select-affected-tests — the live tree', () => { }); }); -describe('ci.yml wires the selector fail-open (objectui#12214)', () => { - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - const code = ci - .split('\n') - .filter((l) => !/^\s*#/.test(l)) - .join('\n'); - - it('runs the full battery hourly on main, beside push', () => { - expect(code).toMatch(/^ {2}schedule:\n {4}- cron: '[^']+'$/m); - expect(code).toMatch(/^ {4}if: github\.event_name == 'push' \|\| github\.event_name == 'schedule'$/m); - }); - - it('selects only on pull_request and merge_group, and treats every other event as full', () => { - const step = code.slice(code.indexOf('- name: Select the tests this change affects')); - expect(step).toMatch(/pull_request\) base="\$PR_BASE"/); - expect(step).toMatch(/merge_group\) base="\$QUEUE_BASE"/); - expect(step).toMatch(/\*\)\n\s+echo 'mode=full' >> "\$GITHUB_OUTPUT"/); - }); - - it('turns a selector that cannot decide into a full run, not a skip', () => { - const step = code.slice(code.indexOf('- name: Select the tests this change affects')); - expect(step).toContain('|| status=$?'); - expect(step).toMatch(/verdict='mode=full'/); - // objectui#6221: the status must not be swallowed by a command substitution. - expect(step).not.toMatch(/\$\(node scripts\/select-affected-tests\.mjs/); - }); - - it('keeps the matrix width and the full-suite command for the full battery', () => { - const run = code.slice(code.indexOf('- name: Run tests (shard ${{ matrix.shard }}/16)')); - expect(run).toContain('pnpm test --shard=${{ matrix.shard }}/16'); - expect(run).toContain('pnpm exec vitest run --shard=${{ matrix.shard }}/16 "${filters[@]}"'); - }); -}); +// `ci.yml wires the selector fail-open (objectui#12214)` retired with +// objectstack#25712. It held objectui ci.yml's `test` job to this selector: +// the hourly full battery, the `Select the tests this change affects` step's +// two selecting events and its fail-open verdict, and the full-suite command +// beside the filtered one. That file runs nowhere under the monorepo. The +// root `test-ui` job that replaced it runs the whole suite on every event and +// does not wire the selector (its comment says why: `git diff --name-only` +// prints `ui/...` paths against a graph spelled relative to the objectui +// root), as row 7 `Test (shard n/16)` of the objectstack#25697 workflow roster +// records. Wiring it there brings these pins back against `test-ui` +// (`helpers/root-ci.ts`). diff --git a/ui/scripts/__tests__/select-type-check-scope.test.ts b/ui/scripts/__tests__/select-type-check-scope.test.ts index 1aea87bfca4..003b9a1b622 100644 --- a/ui/scripts/__tests__/select-type-check-scope.test.ts +++ b/ui/scripts/__tests__/select-type-check-scope.test.ts @@ -5,10 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import { parse as parseYaml } from 'yaml'; import { - FIXED_ROOT_FILES, globToRegExp, main, rootChanges, @@ -107,16 +105,8 @@ describe('select-type-check-scope — the root rule (objectui#12222)', () => { expect(rootChanges(['packages/plugin-kanban/src/x.ts', 'docs/x.md'], rule)).toHaveLength(2); }); - it('names the fixed half of the rule in the workflow, next to the filter', () => { - const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); - const at = ci.indexOf('- name: Decide which packages to type-check'); - expect(at, 'ci.yml’s type-check job no longer has the scope decision step').toBeGreaterThan(-1); - const comment = ci.slice(ci.lastIndexOf('THE ROOT RULE', at), at); - for (const glob of FIXED_ROOT_FILES) { - const spelled = glob.replace(/\/\*\*$/, '/'); - expect(comment, `the root rule stated in ci.yml no longer names ${glob}`).toContain(spelled); - } - }); + // `names the fixed half of the rule in the workflow, next to the filter` + // retired with objectstack#25712; see the end of this file. it('refuses glob syntax it does not implement rather than guessing a match', () => { expect(() => globToRegExp('scripts/{a,b}.mjs')).toThrow(/does not implement/); @@ -248,59 +238,15 @@ describe('select-type-check-scope — the verdict', () => { }); }); -describe('ci.yml — the two speeds are wired the way the selector assumes', () => { - const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8')) as { - jobs: Record }> }>; - }; - const steps = workflow.jobs['type-check'].steps; - const decide = steps.find((s) => s.id === 'tc-scope'); - const runStep = steps.find((s) => s.name === 'Run type-check'); - - it('runs the full battery, forced, on every event that is not a pull request or a queue build', () => { - expect(decide?.run, 'the scope decision step is gone').toBeDefined(); - expect(decide?.run).toMatch(/pull_request\) base="\$PR_BASE"/); - expect(decide?.run).toMatch(/merge_group\) base="\$QUEUE_BASE"/); - expect(decide?.run).toMatch(/\*\)\s+echo 'scope=full'/); - expect(runStep?.env?.TURBO_FORCE).toBe("${{ steps.tc-scope.outputs.scope == 'full' }}"); - }); - - it('fails OPEN: no verdict, no base sha, or an unknown answer type-checks the whole workspace', () => { - expect(decide?.run).toContain('node scripts/select-type-check-scope.mjs --base "$base"'); - // The catch-all branch of the verdict `case` is the whole workspace. - expect(decide?.run).toMatch(/\*\)\s+echo "::warning::the type-check scope selector reached no verdict[^\n]*\n\s+echo 'scope=workspace'/); - expect(decide?.run).toMatch(/if \[ -z "\$base" \]; then[\s\S]*?status=2/); - }); - - it('filters only on the affected verdict, with turbo’s own dependents filter', () => { - expect(runStep?.run).toContain('if [ "$SCOPE" = \'affected\' ]; then'); - expect(runStep?.run).toContain('pnpm type-check "--filter=...[$BASE]"'); - }); - - /** - * objectui#12272: on the affected speed nothing else builds the packages the - * root setup program resolves, so this step must exist, run on that speed - * only, and sit between the turbo run and the root program. - */ - it('builds the root programs’ packages on the affected speed, after the turbo run and before the root program', () => { - const names = steps.map((s) => s.name); - const at = names.indexOf('Build the packages the repo-root programs import'); - expect(at, 'the type-check job no longer builds the root programs’ packages on the affected speed').toBeGreaterThan(-1); - expect(at).toBeGreaterThan(names.indexOf('Run type-check')); - expect(at).toBeLessThan(names.indexOf('Type-check repo-root vitest setup files')); - - const build = steps[at]; - expect(build.if).toContain("steps.relevant.outputs.should_run == 'true'"); - expect(build.if, 'the full battery is unchanged: this step runs on the affected speed only').toContain( - "steps.tc-scope.outputs.scope == 'affected'", - ); - expect(build.run).toContain('node scripts/select-type-check-scope.mjs --root-builds'); - // `turbo run build`, never `type-check` with the names added: that task - // waits on `^build` and builds no named package's own declarations. - expect(build.run).toContain('pnpm exec turbo run build "${filters[@]}"'); - expect(build.run, 'a failed build piped into `tee` must still fail the step').toContain('set -o pipefail'); - // `Report what the turbo cache replayed` reads the NEWEST run summary; this - // run must not write one, or it would replace `Run type-check`'s. - expect(build.env?.TURBO_RUN_SUMMARY).toBeUndefined(); - expect(build.run).not.toContain('TURBO_RUN_SUMMARY'); - }); -}); +// `ci.yml — the two speeds are wired the way the selector assumes` retired with +// objectstack#25712, and so did `names the fixed half of the rule in the +// workflow, next to the filter` above. Both read objectui ci.yml's `type-check` +// job: its `Decide which packages to type-check` step (`tc-scope`), the +// `Run type-check` step's `TURBO_FORCE` and affected filter, and the +// root-programs build between them. That file runs nowhere under the monorepo. +// The root `type-check-ui` job that replaced it does not run this selector: it +// takes the affected scope from turbo's own `...[]` filter and builds the +// root programs' packages in a step of its own, as row 7 `Type Check` of the +// objectstack#25697 workflow roster records ("affected scope via turbo +// `...[base]`"). Wiring the selector there brings these pins back against +// `type-check-ui` (`helpers/root-ci.ts`). diff --git a/ui/scripts/__tests__/shard-headroom.test.ts b/ui/scripts/__tests__/shard-headroom.test.ts index cdf9372f0c5..c240dd5d080 100644 --- a/ui/scripts/__tests__/shard-headroom.test.ts +++ b/ui/scripts/__tests__/shard-headroom.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; // Plain-JS CI helper; its types are INFERRED from the .mjs source by // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. @@ -93,34 +92,12 @@ describe('⛔ not a gate', () => { }); }); -describe('ci.yml wires it as a reading of the right matrix and the right cap', () => { - interface Step { name?: string; run?: unknown; if?: string; 'continue-on-error'?: boolean } - interface Job { name?: string; 'timeout-minutes'?: number; steps?: Step[]; strategy?: { matrix?: { shard?: unknown[] } } } - const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8')) as { jobs: Record }; - - // aggregator -> the matrix job whose shards it reads - const wiring = [ - { aggregator: 'test-aggregate', matrix: 'test' }, - { aggregator: 'coverage-report', matrix: 'test-coverage' }, - ]; - - for (const { aggregator, matrix } of wiring) { - it(`${aggregator} reads ${matrix}: its job name, its width and its timeout-minutes`, () => { - const steps = (workflow.jobs[aggregator]?.steps ?? []).filter( - (s) => typeof s.run === 'string' && s.run.includes('scripts/shard-headroom.mjs'), - ); - expect(steps, `${aggregator} must run the headroom reading exactly once`).toHaveLength(1); - const [step] = steps; - const run = step.run as string; - const target = workflow.jobs[matrix]; - const width = (target.strategy?.matrix?.shard ?? []).length; - expect(run).toContain(`--job '${String(target.name).replace('${{ matrix.shard }}', '{n}')}'`); - expect(run).toContain(`--shards ${width} `); - expect(run).toContain(`--cap-minutes ${target['timeout-minutes']} `); - // ⛔ Not a gate: it runs whatever happened above it, and nothing it - // does can change the aggregator's conclusion. - expect(step.if).toBe('always()'); - expect(step['continue-on-error']).toBe(true); - }); - } -}); +// `ci.yml wires it as a reading of the right matrix and the right cap` retired +// with objectstack#25712. It held the headroom step's `--job`, `--shards` and +// `--cap-minutes` to the two objectui ci.yml aggregators that ran it, +// `test-aggregate` (over `test`) and `coverage-report` (over `test-coverage`), +// and that file runs nowhere under the monorepo. Both are row 7 of the +// objectstack#25697 workflow roster: `Test (the shard aggregate)`, "dropped +// until cutover", and `Test (coverage shard n/8) · Test (coverage)`, "kept, +// follow-up port". No root job runs this reading until one of them is ported; +// the port brings the wiring pin back against its job (`helpers/root-ci.ts`). diff --git a/ui/scripts/__tests__/test-file-timings.test.ts b/ui/scripts/__tests__/test-file-timings.test.ts index 1f74262d63d..162342a2734 100644 --- a/ui/scripts/__tests__/test-file-timings.test.ts +++ b/ui/scripts/__tests__/test-file-timings.test.ts @@ -2,8 +2,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; // Plain-JS CI helpers; their types are INFERRED from the .mjs source by // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. @@ -26,11 +24,10 @@ import { TIMINGS_SCHEMA, datasetWeights, pathKey } from '../test-shard-partition /** * The per-file timing pipeline (objectui#12208): the reporter that measures on * the full battery, the selector that picks which runs feed the dataset, and - * the generator that writes `scripts/test-shard-timings.json`. Plus the pins - * that hold `ci.yml` to the names and flags this file spells. + * the generator that writes `scripts/test-shard-timings.json`. The pins that + * held objectui's `ci.yml` to the names and flags this file spells retired + * with it (see the end of this file). */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const ciYaml = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); /** A TestModule-shaped object, as vitest 4 hands one to `onTestRunEnd`. */ function testModule(file: string, d: Record, project = 'unit') { @@ -184,48 +181,14 @@ describe('the selector', () => { }); }); -describe("ci.yml spells what this file spells (objectui#12208)", () => { - interface Step { name?: string; run?: unknown; uses?: string; with?: Record; env?: Record; if?: string } - const workflow = parseYaml(ciYaml) as { jobs: Record }; - const test = workflow.jobs.test; - const width = (test.strategy?.matrix?.shard ?? []).length; - - it('names the test shards the way the selector looks for them', () => { - expect(test.name).toBe(shardJobName(Number.NaN, width).replace('NaN', '${{ matrix.shard }}')); - }); - - it('runs the full battery with the timing reporter AND the two reporters it would otherwise displace', () => { - const run = test.steps?.find((s) => typeof s.run === 'string' && s.run.includes('pnpm test --shard=')); - expect(run, "the `test` job no longer runs `pnpm test --shard=`").toBeDefined(); - const full = (run!.run as string).split('\n').find((l) => l.includes('pnpm test --shard=')) ?? ''; - // A CLI `--reporter` REPLACES vitest's default set, so naming only the - // timing reporter would silently drop the log and the per-test - // annotations a red leg is read by (the objectui#9177 shape). - for (const flag of ['--reporter=default', '--reporter=github-actions', '--reporter=./scripts/test-file-timings.mjs']) { - expect(full, `the full-battery invocation lacks ${flag}`).toContain(flag); - } - expect(run!.env?.[OUT_ENV], `the run step must name ${OUT_ENV}`).toMatch(/test-file-timings\/shard-\$\{\{ matrix\.shard \}\}\.json$/); - }); - - it('has the weekly refresh select runs at the width the `test` matrix runs (objectui#12384)', () => { - // The selector judges a run by the leg NAMES of one width, so a refresh - // left on an old width after the matrix moves takes no run, ever -- and - // that ends in its NOT MEASURED warning, a green job, and a dataset that - // silently stops being refreshed. Held here, locally and loudly, the way - // `test-aggregate`'s `--shards` is held in `check-test-shard-results.test.ts`. - const refresh = fs.readFileSync(path.join(repoRoot, '.github/workflows/test-shard-timings-refresh.yml'), 'utf8'); - const selects = [...refresh.matchAll(/test-file-timings\.mjs select --shards (\d+)\b/g)].map((m) => Number(m[1])); - expect(selects, 'the refresh must run the selector exactly once').toHaveLength(1); - expect(width, 'ci.yml must still declare a `shard:` matrix on `test`').toBeGreaterThan(0); - expect(selects[0], "the refresh's --shards and ci.yml's `test` matrix have drifted apart").toBe(width); - }); - - it('uploads each shard under the artifact name the selector requires, on the full battery only', () => { - const upload = test.steps?.find((s) => s.uses?.startsWith('actions/upload-artifact@') && String(s.with?.name).startsWith('test-file-timings-')); - expect(upload, "the `test` job no longer uploads its per-file timings").toBeDefined(); - expect(upload!.with?.name).toBe(artifactName(Number.NaN).replace('NaN', '${{ matrix.shard }}')); - expect(upload!.if).toContain("github.event_name == 'push'"); - expect(upload!.if).toContain("github.event_name == 'schedule'"); - expect(upload!.with?.['if-no-files-found'], 'a missing reading must not turn a green leg red').toBe('warn'); - }); -}); +// `ci.yml spells what this file spells (objectui#12208)` retired with +// objectstack#25712. It held objectui ci.yml's `test` job to this file's names +// and flags: the `Test (shard N/M)` job name, the timing reporter beside the +// two it would displace, the per-shard `test-file-timings-N` upload, and the +// weekly refresh's `--shards`. That ci.yml runs nowhere under the monorepo, and +// the root `test-ui` job that replaced it carries none of the four on purpose: +// its comment defers the reporter and the upload to the refresh workflow, row +// 28 `test-shard-timings-refresh.yml` of the objectstack#25697 workflow roster +// (→ sub-issue 4, "when ported, restore Test UI's timings reporter and +// upload"), and row 7 `Test (shard n/16)` records the same wait. The port +// brings these pins back against `test-ui` (`helpers/root-ci.ts`). diff --git a/ui/scripts/__tests__/turbo-cache-save-bound.test.ts b/ui/scripts/__tests__/turbo-cache-save-bound.test.ts deleted file mode 100644 index 2bf9bdf8a8d..00000000000 --- a/ui/scripts/__tests__/turbo-cache-save-bound.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); -const CI = path.join(ROOT, '.github/workflows/ci.yml'); - -/** - * objectui#6577 — cache bookkeeping voided a verdict the gate had already - * recorded. - * - * On 2026-08-26 the merge-group build of #6571 was ejected from the queue with - * `CI_FAILURE` while every substantive check passed. All 21 real steps of the - * `Type Check` job succeeded — `Run type-check` reported success at 13:31:31Z — - * and then `Post Turbo Cache`, the save phase of `actions/cache@v6`, spent - * 13m09s (789s) inside the upload and was still there when the job's - * `timeout-minutes: 20` fired at 20m02s. The job went `cancelled`, the merge - * queue cannot tell `cancelled` from `failure`, and a pull request that had - * passed was dequeued. It was transient, not structural: the same job on the - * same PR head thirteen minutes earlier went green in 5m53s with a cache save - * of ONE SECOND. - * - * The ordering is the whole point, and it is what this file exists to keep - * true: the verdict is recorded by the job's command steps; everything after - * them is bookkeeping. Bookkeeping must be bounded, and its failure must not - * be able to speak for the code. - * - * Why this needs a pin at all — the fix is invisible when reverted. Folding - * the split back into one `actions/cache` step, or dropping either key from - * the save, restores the defect with nothing going red: the cache still works, - * every run still passes, and the next transient upload stall ejects the next - * green pull request. That is this repository's recurring "looks like - * enforcement, isn't" class (objectui#3009, #3181, #3494), and it is why the - * sibling #5304 apt fix shipped with `ensure-chromium-ready.test.ts` beside it. - * - * Deliberately NOT asserted: that the restore half is bounded. A restore stall - * fails BEFORE any verdict exists — a gate that did not run, which is honest — - * rather than a recorded verdict discarded, and bounding it would force a cold - * check under the same job ceiling. That is a different card if it ever fires. - */ - -interface Step { - name?: string; - uses?: string; - run?: string; - id?: string; - if?: string; - 'timeout-minutes'?: number; - 'continue-on-error'?: boolean; - with?: Record; -} -interface Job { - 'timeout-minutes'?: number; - steps: Step[]; -} - -const workflow = parseYaml(fs.readFileSync(CI, 'utf8')) as { jobs: Record }; - -function typeCheckJob(): Job { - const job = workflow.jobs['type-check']; - expect(job, 'ci.yml must still define a `type-check:` job').toBeDefined(); - // Non-vacuity: every assertion below scans this step list, so a parse that - // collapsed it to nothing would make all of them pass while reading nothing. - expect( - job.steps.length, - 'the `type-check` job parsed to implausibly few steps — the scan below would be vacuous', - ).toBeGreaterThan(10); - return job; -} - -const saveStep = (job: Job): Step => { - // By PATH: the job also saves the pinned @objectstack build (objectui#12957), - // a second cache this file is not about. - const found = job.steps.filter( - (s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache', - ); - expect( - found.length, - 'the `type-check` job must save `.turbo/cache` through exactly one `actions/cache/save` ' + - 'step. That step is where the bound lives; without it there is nothing to bound.', - ).toBe(1); - return found[0]; -}; - -describe('ci.yml — the type-check cache save is bounded and non-fatal (objectui#6577)', () => { - it('saves through a MAIN-phase step, never the combined action', () => { - // Combined `actions/cache` declares `main: dist/restore/index.js` and - // `post: dist/save/index.js`. Its save is therefore a step the RUNNER - // generates at job end (`Post Turbo Cache`, step #40 in the incident), and - // no workflow syntax attaches `timeout-minutes` or `continue-on-error` to a - // generated post step. Splitting is not a style preference — it is the only - // way the two keys below can exist at all. `actions/cache`'s own - // `save-always` deprecation text points at this same split. - const combined = typeCheckJob() - .steps.filter((s) => /^actions\/cache@/.test(s.uses ?? '')) - .map((s) => s.name ?? '(unnamed)'); - - expect( - combined, - 'the `type-check` job is back on the combined `actions/cache` action:\n' + - combined.map((n) => ` - ${n}`).join('\n') + - '\n\nIts save runs as a runner-generated POST step, which cannot carry ' + - '`timeout-minutes` or `continue-on-error` — so an upload stall runs the job into its ' + - 'ceiling and cancels a gate that had already passed (objectui#6577). Use ' + - '`actions/cache/restore` plus a bounded `actions/cache/save`.', - ).toEqual([]); - }); - - it('bounds the save, and does not let a failed save speak for the code', () => { - const save = saveStep(typeCheckJob()); - - expect( - typeof save['timeout-minutes'], - 'the cache save step must declare `timeout-minutes`. It is the only thing standing ' + - 'between a stalled upload and the job ceiling, and the job ceiling reports `cancelled` — ' + - 'a gate that says nothing at all (objectui#6577).', - ).toBe('number'); - - expect( - save['continue-on-error'], - 'the cache save step must declare `continue-on-error: true`. Without it the bound only ' + - 'trades a `cancelled` gate for a red one: a cache that failed to upload costs the next ' + - 'run some time and says nothing about the code under test.', - ).toBe(true); - }); - - it('leaves the verdict path at least as much ceiling as bookkeeping can consume', () => { - // Derived rather than hard-coded: whatever the two numbers become, the - // bookkeeping half must not be able to crowd out the half that produces the - // answer. At the values this landed with (5 of 20) the save cannot reach - // the ceiling from any verdict path yet observed — the slowest on record - // finished 6m53s in. - const job = typeCheckJob(); - const ceiling = job['timeout-minutes']; - const bound = saveStep(job)['timeout-minutes']; - - expect(typeof ceiling, 'the `type-check` job must still declare `timeout-minutes`').toBe('number'); - expect( - (bound as number) * 2, - `the cache save may run for ${bound} of the job's ${ceiling} minutes. A bound that large ` + - 'can still starve the checking steps of the ceiling and cancel the job — which is the ' + - 'defect, not the fix.', - ).toBeLessThanOrEqual(ceiling as number); - }); - - it('runs the save AFTER every command step — bookkeeping follows the verdict', () => { - // The card's central sentence, made mechanical. A save hoisted above a - // checking step is bookkeeping that can still delay, and be blamed for, an - // answer that has not been produced yet. - const job = typeCheckJob(); - const lastCommand = job.steps.map((s) => s.run !== undefined).lastIndexOf(true); - const saveAt = job.steps.indexOf(saveStep(job)); - - expect(lastCommand, 'the `type-check` job must still run commands').toBeGreaterThan(-1); - expect( - saveAt, - "the cache save must come after the job's last command step. The verdict is recorded by " + - `\`${job.steps[lastCommand].name}\`; everything after it is bookkeeping, and that ordering ` + - 'is what makes the save safe to bound and safe to lose (objectui#6577).', - ).toBeGreaterThan(lastCommand); - }); - - it('still caches the same thing the combined step did', () => { - // The split must not quietly stop caching. Same path, same key, and the - // save guarded on the restore's `cache-hit` — which is how the combined - // action itself decided to skip a save after an exact primary-key hit. - const job = typeCheckJob(); - const save = saveStep(job); - const restores = job.steps.filter( - (s) => (s.uses ?? '').startsWith('actions/cache/restore@') && s.with?.path === '.turbo/cache', - ); - - expect(restores.length, 'the `type-check` job must restore `.turbo/cache`').toBe(1); - const restore = restores[0]; - - expect(restore.with?.path, 'the restore step must still name `.turbo/cache`').toBe('.turbo/cache'); - expect( - save.with?.path, - 'the save step must write back the same path the restore step read, or the split has ' + - 'silently stopped caching', - ).toBe(restore.with?.path); - expect( - save.with?.key, - 'the save step must write the same key the restore step asked for, or every run misses', - ).toBe(restore.with?.key); - - expect( - restore.id, - 'the restore step needs an `id` so the save step can read its `cache-hit` output', - ).toBeTruthy(); - expect( - save.if ?? '', - `the save step must skip on an exact hit of the primary key — ` + - `\`steps.${restore.id}.outputs.cache-hit != 'true'\`. The combined action did this ` + - `internally; after the split it is the workflow's job, and without it every re-run ` + - `re-uploads a cache that already exists.`, - ).toContain(`steps.${restore.id}.outputs.cache-hit != 'true'`); - }); - - it('does not raise the job ceiling, which is the ruled-out non-fix', () => { - // Ruled out on the card itself: a larger ceiling buys a longer hang and - // still ends in `cancelled`, and lifting a gate's ceiling weakens the gate. - // Raising this for some UNRELATED reason is a decision someone may well - // need to make — make it deliberately, and move this pin in the same - // commit, rather than discovering later that #6577's fix was undone by it. - expect( - typeCheckJob()['timeout-minutes'], - 'the `type-check` job ceiling moved. Raising it does not fix a hung cache save — the hang ' + - 'just runs longer and the gate still reports `cancelled` (objectui#6577).', - ).toBeLessThanOrEqual(20); - }); -}); diff --git a/ui/scripts/__tests__/vitest-config-type-check.test.ts b/ui/scripts/__tests__/vitest-config-type-check.test.ts index 21c78a99d7d..9d0e6625640 100644 --- a/ui/scripts/__tests__/vitest-config-type-check.test.ts +++ b/ui/scripts/__tests__/vitest-config-type-check.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; + /** * objectui#7328 — the repo-root `vitest.config.mts` had no RUNNABLE gate. * @@ -42,7 +44,6 @@ import ts from 'typescript'; */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const configPath = path.join(repoRoot, 'apps/console/tsconfig.node.json'); -const ciWorkflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); /** The root script CI invokes, and the one a contributor can run locally. */ const SCRIPT_NAME = 'type-check:vitest-config'; @@ -184,15 +185,14 @@ describe('the gate is actually wired up (objectui#7328)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; - const ciWorkflow = fs.readFileSync(ciWorkflowPath, 'utf8'); - /** The `type-check:` job body, from its key up to the next job at the same indent. */ + /** + * The `type-check-ui:` job body of objectstack's root ci.yml, which is + * objectui's `Type Check` (objectstack#25697). The ci.yml under `ui/.github/` + * runs nowhere (objectstack#25712). + */ function typeCheckJob(): string { - const start = ciWorkflow.search(/^ {2}type-check:[ \t]*$/m); - expect(start, 'ci.yml must still have a `type-check:` job').toBeGreaterThan(-1); - const rest = ciWorkflow.slice(start + 1); - const next = rest.search(/^ {2}[a-z0-9][a-z0-9-]*:[ \t]*$/m); - return next === -1 ? rest : rest.slice(0, next); + return jobBlock(rootCi(), UI_JOBS.typeCheck); } it('is runnable locally, not CI-only', () => { @@ -219,22 +219,23 @@ describe('the gate is actually wired up (objectui#7328)', () => { it('runs in ci.yml’s type-check job', () => { expect( typeCheckJob(), - `ci.yml’s type-check job must run \`pnpm ${SCRIPT_NAME}\`. Without it the script is a ` + + `ci.yml’s \`type-check-ui\` job must run \`pnpm --dir ui run ${SCRIPT_NAME}\`. Without it the script is a ` + 'command nobody runs, and the root Vitest config is back to being covered only when the ' + 'console’s own type-check happens to run.', - ).toContain(`pnpm ${SCRIPT_NAME}`); + ).toContain(uiRun(SCRIPT_NAME)); }); it('runs after the install and BEFORE the build, in the cheap half of the job', () => { const job = typeCheckJob(); const install = job.indexOf('pnpm install --frozen-lockfile'); - const check = job.indexOf(`pnpm ${SCRIPT_NAME}`); - // A line that IS the bare command, as `run:`'s value or inside a `run: |` - // block -- objectui#12222 put the two speeds' branches in that step. - const build = job.search(/^\s+(?:run: )?pnpm type-check$/m); + const check = job.indexOf(uiRun(SCRIPT_NAME)); + // The root job's `Run type-check over the ui packages` step: turbo's + // `type-check` task, whose `^build` is the workspace build. Both of its + // speeds' branches start a line with the command. + const build = job.search(/^\s+pnpm exec turbo run type-check\b/m); expect(install, 'the type-check job must still install dependencies').toBeGreaterThan(-1); - expect(build, 'the type-check job must still run `pnpm type-check`').toBeGreaterThan(-1); + expect(build, 'the `type-check-ui` job must still run turbo’s `type-check` task').toBeGreaterThan(-1); expect(check, `the type-check job must run \`pnpm ${SCRIPT_NAME}\``).toBeGreaterThan(-1); expect(check, `\`pnpm ${SCRIPT_NAME}\` needs tsc and the vite types from node_modules`).toBeGreaterThan( diff --git a/ui/scripts/__tests__/vitest-setup-type-check.test.ts b/ui/scripts/__tests__/vitest-setup-type-check.test.ts index 39fd5732f1f..5779643d417 100644 --- a/ui/scripts/__tests__/vitest-setup-type-check.test.ts +++ b/ui/scripts/__tests__/vitest-setup-type-check.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { jobBlock, rootCi, uiRun, UI_JOBS } from './helpers/root-ci'; + /** * objectui#3515 — the four repo-ROOT `vitest.setup.*` files were in zero tsc * programs. @@ -32,7 +34,6 @@ import ts from 'typescript'; */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const configPath = path.join(repoRoot, 'tsconfig.vitest-setup.json'); -const ciWorkflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); /** The root script CI invokes, and the one a contributor can run locally. */ const SCRIPT_NAME = 'type-check:vitest-setup'; @@ -235,15 +236,14 @@ describe('the gate is actually wired up (objectui#3515)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; - const ciWorkflow = fs.readFileSync(ciWorkflowPath, 'utf8'); - /** The `type-check:` job body, from its key up to the next job at the same indent. */ + /** + * The `type-check-ui:` job body of objectstack's root ci.yml, which is + * objectui's `Type Check` (objectstack#25697). The ci.yml under `ui/.github/` + * runs nowhere (objectstack#25712). + */ function typeCheckJob(): string { - const start = ciWorkflow.search(/^ {2}type-check:[ \t]*$/m); - expect(start, 'ci.yml must still have a `type-check:` job').toBeGreaterThan(-1); - const rest = ciWorkflow.slice(start + 1); - const next = rest.search(/^ {2}[a-z0-9][a-z0-9-]*:[ \t]*$/m); - return next === -1 ? rest : rest.slice(0, next); + return jobBlock(rootCi(), UI_JOBS.typeCheck); } it('is runnable locally, not CI-only', () => { @@ -258,10 +258,10 @@ describe('the gate is actually wired up (objectui#3515)', () => { it('runs in ci.yml’s type-check job', () => { expect( typeCheckJob(), - `ci.yml’s type-check job must run \`pnpm ${SCRIPT_NAME}\`. Without it, ` + + `ci.yml’s \`type-check-ui\` job must run \`pnpm --dir ui run ${SCRIPT_NAME}\`. Without it, ` + 'tsconfig.vitest-setup.json is a file that looks like a gate while no CI job reads it — ' + 'the same shape as the unchecked setup files it was added to fix.', - ).toContain(`pnpm ${SCRIPT_NAME}`); + ).toContain(uiRun(SCRIPT_NAME)); }); /** @@ -274,12 +274,13 @@ describe('the gate is actually wired up (objectui#3515)', () => { */ it('runs AFTER the build, because its program needs built declarations', () => { const job = typeCheckJob(); - // A line that IS the bare command, as `run:`'s value or inside a `run: |` - // block -- objectui#12222 put the two speeds' branches in that step. - const build = job.search(/^\s+(?:run: )?pnpm type-check$/m); - const check = job.indexOf(`pnpm ${SCRIPT_NAME}`); + // The root job's `Run type-check over the ui packages` step: turbo's + // `type-check` task, whose `^build` is the workspace build. Both of its + // speeds' branches start a line with the command. + const build = job.search(/^\s+pnpm exec turbo run type-check\b/m); + const check = job.indexOf(uiRun(SCRIPT_NAME)); - expect(build, 'the type-check job must still run `pnpm type-check`').toBeGreaterThan(-1); + expect(build, 'the `type-check-ui` job must still run turbo’s `type-check` task').toBeGreaterThan(-1); expect(check, 'the type-check job must run the vitest-setup type-check').toBeGreaterThan(-1); expect( check, diff --git a/ui/scripts/markdown-test-inputs.mjs b/ui/scripts/markdown-test-inputs.mjs index c2f697563ca..2713aee1f8f 100644 --- a/ui/scripts/markdown-test-inputs.mjs +++ b/ui/scripts/markdown-test-inputs.mjs @@ -722,14 +722,6 @@ export const ADJUDICATED = new Map([ // opened were recorded, which is also how the three tests that ENUMERATE a // markdown population without opening it were caught. // --------------------------------------------------------------------------- - // Reads no markdown: the document literal is an ARGUMENT to a path-matcher assertion, never opened. - [ - 'scripts/__tests__/check-action-forward-parity.test.ts', - { - reads: [], - notRead: ['content/docs/guide/ci-cd-pipeline.md'], - }, - ], // objectui#9727 -- the rebuilt polarity census. It walks TWO markdown trees: // the real pending corpus (through the script, for the controls and the // corpus-boundary pin) and its own fixture corpus (for every instrument pin). @@ -1038,15 +1030,6 @@ export const ADJUDICATED = new Map([ walker: 'markdown-tree', }, ], - // Reads no markdown: reads workspace manifests and turbo inputs; the `README.md` literal is a declared-input fixture. - [ - 'scripts/__tests__/docs-build-trigger.test.ts', - { - reads: [], - notRead: ['README.md'], - walker: 'not-markdown: workspace `package.json` manifests and turbo input globs', - }, - ], // `./*` for the same reason as `check-doc-links.test.ts` above, reached by a // different route: this one takes its population from `trackedFiles()` in // `dollar-dialect-alias-census.mjs`, which is `git ls-files -z` -- EVERY