Skip to content

Test: fail CI when a root test script targets a vitest project no workflow runs - #418

Open
AmaadMartin wants to merge 2 commits into
mainfrom
feat/workflow-script-ci-guard
Open

Test: fail CI when a root test script targets a vitest project no workflow runs#418
AmaadMartin wants to merge 2 commits into
mainfrom
feat/workflow-script-ci-guard

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    No existing issue.
  2. Or, if no issue exists, describe the change:
    Problem: Nothing today proves that the test scripts declared in the root package.json are actually executed by a GitHub Actions workflow. If someone drops a --project <name> flag from test:coverage, or adds a new test:* script that no workflow ever calls, CI stays green while those tests silently stop running. Because the integration vitest project is where the repo's own meta-guards live, that failure mode is self-concealing — the guards stop running too, so nothing is left to notice.

Solution: Add one dependency-free meta test, tests/integration/repo_config/workflow_scripts_test.ts, that walks the chain root npm test script -> vitest project -> GitHub workflow step and fails loudly when a link is missing.

It reads only two things off disk (no network, no subprocess, no new dependency): the root package.json scripts map (via a static JSON import, so the path is resolved relative to the test file and verified at compile time — never process.cwd()), and the raw text of .github/workflows/*.{yml,yaml}. From those it derives:

  • Invoked scripts — every npm run <script> occurrence in the workflow files, with whole-line # comments stripped first so a commented-out step does not read as a live invocation.
  • Workflow-run projects — the union of the --project flags in the commands of those invoked scripts.
  • The invariant — for every root script matching /^test(:|$)/, every vitest project that script targets must be a member of Workflow-run projects.

The failure output is the vitest diff of a message array against [], so every broken link is spelled out as a full sentence naming both the orphaned project and the script that declares it.

There is a second, cheaper assertion that keeps the guard from ever passing vacuously: every npm run name found in a workflow must exist in the root scripts map, and both the workflow-file count and the workflow-run project set are asserted non-empty. If the regex ever stops matching, the suite goes red instead of quietly finding nothing.

Collision check (required before implementation): gh pr list --repo AmaadMartin/adk-js --state open --limit 100 returned 100 open PRs. The only adjacent one is this fork's PR #343, "Test: fail CI when a vitest project is not run by any npm script" (fix/vitest-project-drift-guard), which adds tests/integration/repo_config/vitest_projects_test.ts. It asserts the other half of the chain (vitest.config.ts projects <-> root script --project flags) and never reads .github/workflows, so it does not land this change. I did not stack on it: the two PRs add two distinct new files and share no code, so there is no rebase conflict to avoid, and stacking would set this PR's base to a non-main branch, which the pull_request: branches: [main] workflow trigger would never fire for. The remaining workflow-touching PRs (#416 Node pinning, #403 timeout-minutes, #393 setup-go cache) edit workflow YAML but add no scripts and no vitest projects, so they cannot conflict with this guard's logic.

Deliberate deviations from the approved plan (all disclosed, none reduce scope):

  1. The plan specified a readRootScripts() helper doing fs.readFile + JSON.parse(...) as {scripts?: Record<string, string>} + ?? {}. I used a static import rootPackage from '../../../package.json' with {type: 'json'} instead. It removes an any-typed JSON.parse, removes a ?? branch that no test could ever reach, resolves the path at compile time rather than at runtime, and matches how the neighbouring file in this directory reads the manifest. tsc --noEmit resolves the JSON import under this repo's module: nodenext config (verified — see Testing Plan).
  2. The plan wrapped the failure messages in [...new Set(...)]. Each message embeds the script name, so two scripts targeting the same missing project produce two different strings and the Set can never deduplicate anything real. Dropped as dead code.
  3. The plan called for exactly two it blocks. I kept both of those verbatim in the describe it specified, and added a second describe('workflow script parsing') with unit tests over the pure helpers. Without them, stripCommentLines would have zero coverage of its actual purpose (no workflow file in the repo currently contains a # comment line, so it is a no-op against live data), and the two regexes would have no negative test. Each is backed by a mutation proof below.

Known, deliberate limitation: the guard models CI coverage via explicit --project flags. A workflow-invoked script that ran vitest with no --project flag would in fact run every project, but this guard would still report the targeted projects as unwired. No such script exists in the repo, and modelling that case would add an unreachable branch, so it is intentionally out of scope.

Deliberately not included: the companion guard for the other half of the chain (every project declared in vitest.config.ts is named by some root script). It is tracked as a separate task and is the subject of this fork's PR #343. This PR imports nothing from it and stands alone.

Review round 1 — why this is not re-anchored on vitest.config.ts. A reviewer asked for the guard's universe of projects to come from vitest.config.ts projects[] rather than from the root scripts, on the grounds that unit:integrations is declared at vitest.config.ts:64, is named by no root script, and so never runs in CI — a real orphan this guard does not report. The orphan is real; re-anchoring is the wrong place to fix it, for three checkable reasons:

  1. It is the other link in the chain. unit:integrations is "declared but targeted by no script" (link A). This guard asserts "targeted by a script but run by no workflow" (link B). No amount of tuning link B can reach a project that no script mentions.
  2. Link A already exists as a live sibling PR. This fork's PR Test: fail CI when a vitest project is not run by any npm script #343 asserts exactly "every project declared in vitest.config.ts is run by a root npm script". Re-anchoring here ships a second implementation of that same assertion.
  3. It would make this PR red, and that fix is in flight too. PR Test: fail CI when a vitest project is not run by any npm script #343's base is not main — it is stacked on PR Fix: run the orphaned unit:integrations vitest project from the root test scripts #236, "Fix: run the orphaned unit:integrations vitest project from the root test scripts", which adds --project unit:integrations to test, test:unit and test:coverage. Test: fail CI when a vitest project is not run by any npm script #343 is stacked on Fix: run the orphaned unit:integrations vitest project from the root test scripts #236 precisely because a link-A guard cannot go green until the orphan is wired. Re-anchoring would inherit that redness, and the only way to clear it is Fix: run the orphaned unit:integrations vitest project from the root test scripts #236's package.json edit — which the approved plan explicitly forbids here ("Additive. Do not edit package.json, vitest.config.ts, any workflow file").

The two guards are complementary, and this one covers cases #343 cannot. Verified rather than asserted: simulating #236 as landed and then unwiring unit:integrations from test:coverage only (leaving it in test and test:unit, so link A stays green) makes this guard fire:

× every vitest project a root test script targets is run by a workflow
+   "vitest project \"unit:integrations\" (from \"npm run test\") is not run by any .github/workflows step",
+   "vitest project \"unit:integrations\" (from \"npm run test:unit\") is not run by any .github/workflows step",

The same asymmetry holds for the headline regression: dropping --project e2e from test:coverage leaves e2e still referenced by test:e2e, so link A stays green and only this guard catches it.

Review round 1 — the /^test(:|$)/ script-name filter is gone (second commit). The reviewer was right that scoping by script name proxies the condition the code actually cares about. I did not simply delete the filter, because the stated premise for deleting it — that "presence of --project already establishes a vitest invocation" — is false: --project is tsc's flag too, and ts:check is actively moving onto it (PRs #326 and #414 both rewrite it as tsc -p tsconfig.check.json; the long spelling is one keystroke away). Deleting the filter outright would make a future "ts:check": "tsc --project tsconfig.check.json" report tsconfig.check.json as an unwired vitest project.

So the name heuristic is replaced by the direct condition, pushed down into the extractor (vitestProjects) so both sides of the comparison use one rule instead of two. Behaviour on the current tree is identical — every root script carrying --project today runs vitest — and the guard is now strictly broader in the useful direction: a vitest script not named test* is no longer ignored. A regression test pins it, and mutating VITEST_COMMAND_PATTERN to match everything fails it:

× workflow script parsing > claims no project for a non-vitest tool that also takes --project
AssertionError: expected [ 'tsconfig.check.json' ] to deeply equal []

Additive and test-only: package.json, package-lock.json, vitest.config.ts, every .github/workflows/* file, and every existing test are untouched. The new file is picked up automatically because vitest.config.ts gives the integration project include: ['tests/integration/**/*_test.ts'], and validation.yaml runs npm run test:coverage, which includes --project integration — so the guard guards itself.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

$ npx vitest run --project integration tests/integration/repo_config/workflow_scripts_test.ts
 ✓ |integration| tests/integration/repo_config/workflow_scripts_test.ts (7 tests) 12ms
 Test Files  1 passed (1)
      Tests  7 passed (7)

Full gate, all green on the pushed commit:

$ npx tsc --noEmit  2>&1 | grep repo_config     # no errors attributed to the new file
$ npm run lint                                  # exit 0
$ npm run format:check                          # "All matched files use Prettier code style!"
$ bash scripts/check_license.sh                 # "All files have the correct license header."
$ npm run build                                 # exit 0

npm run ts:check is not green on main at b390217e independently of this change (pre-existing errors across ~100 files, mostly unresolved @google/adk specifiers). I verified the narrower, honest claim instead: tsc --noEmit attributes zero errors to the new file. To confirm the file is genuinely in the program rather than silently skipped, I temporarily appended const _probe: number = rootPackage.scripts.test; and re-ran — tsc reported tests/integration/repo_config/workflow_scripts_test.ts:162:7 - error TS2322: Type 'string' is not assignable to type 'number', which also proves the JSON import resolves to a precisely typed value.

CI on this PR. Green on the current head (9c184ebf) — the real test jobs ran and passed on every platform, and the new file passed 7/7 on each:

run-tests (ubuntu-latest)   pass  5m53s   ✓ workflow_scripts_test.ts (7 tests)
run-tests (macos-latest)    pass  6m10s   ✓ workflow_scripts_test.ts (7 tests)
run-tests (windows-latest)  pass  8m47s   ✓ workflow_scripts_test.ts (7 tests)
run-tests (cross-language)  pass  1m37s
check-license               pass

For the record, the first push of this branch saw windows-latest go red, and it is worth stating why it is not a flake in this change. It failed on two pre-existing integration tests this PR does not touch — tests/integration/app_loader/app_loader_test.ts timing out at 40 s, and tests/integration/test_case_utils.ts:341 throwing CLI exited prematurely with code 1. The validation run for the unmodified base commit b390217e reproduces the identical CLI exited prematurely with code 1 at the same line and shows app_loader_test.ts burning 72,989 ms on Windows, i.e. that test is already far over budget on main and tips over on a slower runner. It passed on the re-run here with no change to either test. That flake is queued as separate work rather than fixed in this diff.

Coverage. The change adds zero lines under core/src, dev/src or integrations/src, the only paths in the vitest coverage include list, so it cannot move the thresholds and none were edited. Coverage of the new file itself is not measurable: @vitest/coverage-v8 drops files matched by the project's test include from the report, and every override I tried (--coverage.include, --coverage.exclude, --coverage.all=false, --coverage.excludeAfterRemap=false) still reported All files | 0 | 0 | 0 | 0 with no row for the file. Verified by construction instead: the file has exactly one branch — the vitest-invocation ternary in vitestProjects, whose both arms are pinned by dedicated tests — no if, no &&/||/??, no default parameters, and all five module-level functions are executed by the seven tests.

Proof that each test can fail. Eight mutations, each applied, run, and reverted; git status was clean afterwards.

Against the guard (mutating the repo, not the test):

  1. Dropped --project e2e from test:coverage in the root package.json:
    × every vitest project a root test script targets is run by a workflow
    AssertionError: A root test script targets a vitest project that no .github/workflows step runs, so those tests never execute in CI.: expected [ …(2) ] to deeply equal []
    +   "vitest project \"e2e\" (from \"npm run test\") is not run by any .github/workflows step",
    +   "vitest project \"e2e\" (from \"npm run test:e2e\") is not run by any .github/workflows step",
    
  2. Added an orphan "test:foo": "vitest --project foo" to the root package.json:
    × every vitest project a root test script targets is run by a workflow
    +   "vitest project \"foo\" (from \"npm run test:foo\") is not run by any .github/workflows step",
    
  3. Renamed the validation.yaml step to run: npm run test:coverageX (parser self-check):
    × every npm run invocation in .github/workflows names a root script
    AssertionError: These .github/workflows steps run npm scripts that the root package.json does not define, so the step cannot do what it says.: expected [ 'test:coverageX' ] to deeply equal []
    

Against the parser unit tests (mutating the file under test):

  1. stripCommentLines made a no-op (.filter(() => true)):
    × workflow script parsing > ignores npm run invocations on commented-out lines
    AssertionError: expected [ 'x' ] to deeply equal []
    
  2. PROJECT_FLAG_PATTERN narrowed to /--project\s+([^\s"']+)/g (loses the --project=name spelling):
    × workflow script parsing > reads both the --project name and --project=name spellings
    AssertionError: expected [ 'unit:core' ] to deeply equal [ 'unit:core', 'e2e' ]
    
  3. VITEST_COMMAND_PATTERN widened to match everything, i.e. the review-round-1 proposal of dropping the scope filter entirely:
    × workflow script parsing > claims no project for a non-vitest tool that also takes --project
    × workflow script parsing > names every unwired project and the script that runs it
    AssertionError: expected [ 'tsconfig.check.json' ] to deeply equal []
    
  4. lastIndex leak on the shared /g regex (PROJECT_FLAG_PATTERN.test(command) before the matchAll):
    × every vitest project a root test script targets is run by a workflow
    × workflow script parsing > reads both the --project name and --project=name spellings
    × workflow script parsing > rescans from the start of the input on a repeated call
    × workflow script parsing > names every unwired project and the script that runs it
    AssertionError: expected [ 'e2e' ] to deeply equal [ 'unit:core', 'e2e' ]
    
  5. Simulated PR Fix: run the orphaned unit:integrations vitest project from the root test scripts #236 as landed, then unwired unit:integrations from test:coverage only — the link-A guard stays green, this one fires (quoted in full in the review-round-1 section above).

All eight were re-run against the revised code after the round-1 change, not carried over from the first round.

One mutation I tried did not produce a failure and is reported for honesty: rewriting the project extractor as an exec loop over the shared /g regex still passes, because RegExp.exec resets lastIndex to 0 when it returns null. Mutation 7 is the one that actually pins the statelessness requirement, which is why it is the one recorded above.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

npm install
npm run build          # required: tests/global_setup.ts imports @google/adk from dist
npx vitest run --project integration tests/integration/repo_config/workflow_scripts_test.ts

Expect 6 passing tests. To watch the guard fire, edit the root package.json and remove --project e2e from test:coverage, then re-run — the suite fails naming e2e and both scripts that declare it. Revert.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 2 commits July 31, 2026 15:51
…kflow runs

Nothing today proves the test scripts in the root package.json are actually
executed by a GitHub Actions workflow. Dropping a --project flag from
test:coverage, or adding a test:* script nobody wires into a workflow, leaves
CI green while those tests silently stop running. The integration project
hosts the repo's own meta-guards, so that failure mode hides itself.

Add a dependency-free guard that walks root test script -> vitest project ->
workflow step by reading package.json and .github/workflows/*.{yml,yaml}, and
fails naming both the orphaned project and the script that declares it.
The guard filtered candidate scripts with a /^test(:|$)/ name pattern, which
proxies the condition it actually cares about: whether the command runs vitest.
Replace it with a direct check on the command itself, applied inside the
project extractor so both sides of the comparison use one rule.

This also closes a false positive the name filter was silently covering:
--project is not vitest-specific. `tsc --project tsconfig.json` is the same
flag, and ts:check is actively moving toward a -p form, so a script picking up
the long spelling would have been reported as an unwired vitest project.

Behaviour on the current tree is unchanged: every root script carrying
--project today runs vitest. Dropping the name filter also widens the guard to
a vitest script that is not named test*, which the old pattern ignored.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant