Fix: make the app_loader integration suite independent of the previous run - #521
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: make the app_loader integration suite independent of the previous run#521AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 2, 2026 05:47
…s run
The suite failed on every other consecutive run on an unmodified checkout,
which regularly cancelled the windows-latest leg of the validation matrix.
Two coupled defects:
* The install hooks ran `npm install` in-tree and only cleaned up in
`afterAll`. A run killed mid-install left a partial `node_modules` that
`npm install` does not clear, so run N's cost -- and outcome -- depended on
how run N-1 ended. The hooks now pre-clean before installing, so determinism
no longer depends on teardown having succeeded. Teardown failures are
reported instead of being swallowed by a blanket `.catch(() => {})`.
* One 40s constant served as both the per-test and the per-hook budget, and
overrode the larger project-level budgets that vitest.config.ts already
defines for exactly this reason. Hooks now get their own named 120s budget
(matching build_setup_test.ts), and the per-test budget is inherited from
the `integration` project rather than shadowed at 40s.
`loader` becomes a `const` initialised at declaration so a failed install can
no longer leave `afterAll` dereferencing `undefined`, and `disposeAll()` runs
in a `try`/`finally` so its failure cannot skip the fixture cleanup.
No assertion is weakened, skipped, or retried.
Three review findings, no behaviour change: * Delete the per-file HOOK_TIMEOUT constant and the four hook timeout arguments. 120000 was byte-identical to INTEGRATION_HOOK_TIMEOUT_MS, which the `integration` project already applies to this path, so the file now inherits both budgets instead of inheriting one and shadowing the other with the same number. Verified the inherited hook budget by forcing an install failure: the runner reports "Hook timed out in 120000ms", not vitest's 10s default. * Move removeInstallArtifacts/cleanUpFixture into test_case_utils.ts, the shared module this suite already imports. They are fixture-install helpers, not app_loader helpers, and build_setup_test.ts open-codes the same teardown today. * Trim the comment prose to the invariant. The rationale lives in the commit body and the PR description, not restated in the source.
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem:
tests/integration/app_loader/app_loader_test.tsfails on everyother consecutive run on an unmodified checkout, and flakes intermittently on
macos-latest. Because thevalidationOS matrix uses the default fail-fast,that flake cancels the
windows-latestjob, so Windows coverage is regularlylost.
Reproduced locally on
mainbefore touching anything — three consecutive runs,nothing else changed between them:
Runs 1 and 3 both reported:
and left partial fixture trees behind:
Two coupled defects produce that alternation:
Defect 1 — fixture state leaks between runs. Both
beforeAllhooks runnpm installin-tree; the matchingafterAllhooks deletenode_modulesandpackage-lock.jsonand swallow every error with.catch(() => {}). A runkilled mid-install leaves a partial
node_modulesbehind, which changes thenext run's install cost — that is the mechanism behind the alternation (run 2
above was fast because run 1 had leaked a warm partial tree; run 3 started
cold again and blew the budget). In the
discoveryblock,afterAllcallsloader.disposeAll()before the filesystem cleanup, so when that throws thecleanup below never runs — the leak is guaranteed on exactly the runs that
already went wrong.
.catch(() => {})also makes a genuineEACCES/EPERM/EBUSYindistinguishable from the benign "already gone" case.Defect 2 — one 40s constant doing two jobs, whose failure mode masks the real
error.
TEST_EXECUTION_TIMEOUT = 40000was passed as both the per-test budgetand the hook budget. 40s is too tight for a cold
npm install, and a per-filebeforeAll(fn, 40000)/it(name, fn, 40000)argument overrides theproject-level budgets
vitest.config.tsalready defines for exactly this reason(
INTEGRATION_HOOK_TIMEOUT_MS = 120000,INTEGRATION_TEST_TIMEOUT_MS = 60000),so the file opted itself back down to 40s. When the hook budget fired,
loader— declared
let loader: AgentLoader;and assigned after the install — wasstill
undefined, soafterAlladded aTypeErroron top of the real failure.Solution: test-infrastructure only; no production source file is touched and
no assertion is weakened, skipped, retried, or removed. Two files:
app_loader_test.tsand the sharedtests/integration/test_case_utils.tsthehelpers live in.
beforeAllhooks now callremoveInstallArtifacts(projectPath)beforenpm install, so every runstarts from an identical fixture state regardless of how the previous run
ended. Determinism is now owned by setup, not by teardown having succeeded.
tests/integration/test_case_utils.ts(the shared module this suite alreadyimports for
sendInput). They are fixture-install helpers, not app_loaderhelpers —
build_setup_test.tsopen-codes the samenode_modules+package-lock.jsonteardown today — so they are shared rather thanco-located.
removeInstallArtifactsis strict:force: truemakes a missingpath a no-op, so anything that still rejects is a real failure and
propagates.
cleanUpFixtureis the teardown variant: it catches,console.warns with the fixture path, and does not rethrow. This asymmetryis intentional: a pre-clean that cannot remove the tree is exactly the
non-deterministic state this change exists to eliminate and must fail loudly,
whereas failing an otherwise-green suite on a teardown
EBUSYwould tradeone flake for another.
.catch(() => {})no longer appears anywhere in thetest file.
fs.unlinkis replaced byfs.rm(..., {force: true}), which iswhy the blanket catch was needed in the first place (
unlinkthrowsENOENT).integrationproject. Theper-file
TEST_EXECUTION_TIMEOUT = 40000is deleted and no replacementconstant is introduced:
vitest.config.tsalready applieshookTimeout: INTEGRATION_HOOK_TIMEOUT_MS = 120000andtestTimeout: INTEGRATION_TEST_TIMEOUT_MS = 60000totests/integration/**/*_test.ts, which matches this path. Declaring aper-file
HOOK_TIMEOUT = 120000would have been a third copy of the samenumber and would re-create the shadowing structure this change exists to
remove. Both budgets are verified empirically below rather than assumed.
loaderis aconstinitialised at declaration. TheAgentLoaderconstructor only records the directory and registers exit handlers
(
dev/src/utils/agent_loader.ts) — it performs no filesystem access, so itis safe to construct before the install.
disposeAll()on a loader thatnever loaded anything is
Promise.all([]).afterAlltherefore always has acallable
loader, anddisposeAll()runs inside atry/finallyso itsfailure can no longer skip the fixture cleanup.
Collision check (required before implementing; recorded here per process).
Scanned all 419 open PRs on the fork:
Eight open PRs also edit this file or its budgets — #506, #499, #478, #407,
#405, #260, #256, #235 (plus #218, which extracts the fixture install/teardown
into a shared
tests/integration/fixture_project.ts). All of them overlaponly on the timeout dimension (item 3 above), and they conflict with each
other: #405/#478 drop the per-file argument entirely, #260 raises it to 180s,
#235 to 120s/180s, #256 to 60s, #506 splits it per-suite. None of them
addresses the state leak: no open PR pre-cleans before installing, #218 still
swallows every teardown error with
.catch(() => {}), and no PR makesloadersafe to dereference in
afterAll. Items 1, 2 and 4 above are unique to this PR.This PR branches from
mainrather than stacking, because there is no single"the" overlapping branch to stack on — the eight candidates are mutually
exclusive, so picking one would make this change un-mergeable if a different one
lands. If any of them merges first, this PR should be rebased onto it and item 3
dropped; items 1, 2 and 4 apply unchanged on top of every one of them.
Deliberately out of scope (the same defect exists there, tracked separately;
this change cannot conflict with either):
tests/integration/agent_loader/agent_dirname_test.tsandtests/integration/skills/script_js/agent_test.tscarry the same hook-budgetand best-effort-cleanup defect, and adding
fail-fast: falseto thevalidation.yamlOS matrix.Known remaining duplication, called out rather than left silent.
build_setup_test.tsstill open-codes the samenode_modules+package-lock.jsonteardown with its own.catch(() => {}), and still declaresits own
HOOK_TIMEOUT = 120000duplicatingINTEGRATION_HOOK_TIMEOUT_MS. Bothare now one-line changes on top of this PR (the helpers it needs are exported
from
test_case_utils.ts), but retiring them touches a second flaky-teardownsuite, so they are left to the separately-queued task for that file rather than
widened into this diff.
No unit tests are added, and that is deliberate. The entire diff lives in a
test file. The coverage report's
includeglobs arecore/src/**,dev/src/**and
integrations/src/**, so nothing here is measured, and the thresholds invitest.config.ts(statements: 86,branches: 87,functions: 88,lines: 86) are unaffected. A unit test for a test-file helper would beinverted; both helpers are exercised on every run of this suite —
removeInstallArtifactstwice per fixture (fourbeforeAlland fourafterAllpaths), and
cleanUpFixture'scatchbranch by the fault injection below.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. — N/A, see above: the
change is confined to a test file that is itself the integration test. No
production code is added, so there is no new line or branch to cover.
[x] All unit tests pass locally.
1. Three consecutive runs, started from the leaked fixture state that baseline
run 3 left behind:
Re-run after the review revision (helpers relocated, budget constants deleted):
pass (6/6) 338.57s, pass (6/6) 336.76s.
Afterwards, no fixture artifacts remain and
git status --porcelainreportsonly the edited files. Note the flat durations: previously the runtime swung
50s ↔ 168s because each run inherited the previous run's tree. (The absolute
number is this machine's cold, proxied
npm install— ~84s per fixture,comfortably inside the 120s hook budget. Steady-state cost is unchanged versus
main: the oldafterAllalready removednode_modulesafter every green run,so the pre-clean is a no-op except on runs that previously leaked.)
2. Mutation test for Defect 1 — proving the pre-clean is load-bearing.
First, the corruption suggested during design (
node_modules/@google/adk/ package.json={}) turned out not to be a valid mutation:npm installrepairs it, and the suite passed with and without the pre-clean. A corruption
that npm genuinely does not repair was needed, so I measured what survives:
npm installdoes not sweep leftover content undernode_modules. The faithfulversion of that — a half-extracted package, exactly what an aborted reify leaves
— is deterministic:
Run against that identical corrupted fixture, with only the pre-clean line
mutated away:
Mutation — the single
await removeInstallArtifacts(projectPath);line deletedfrom the
discoverybeforeAll: FAILSLine restored, same corruption re-applied: PASSES (
1 passed | 5 skipped).Both directions were re-run after the review revision moved the helper into
test_case_utils.ts, and both still hold.3. Mutation test for Defect 2 — proving the
loaderguard removes themasking, and simultaneously proving the inherited hook budget. The
discoveryinstall was forced to fail (
npm install --registry http://127.0.0.1:1/):With
const loader = new AgentLoader(projectPath)(this PR) — exactly oneerror, the real one;
grep -c TypeErrorreturns 0:Reverted to
let loader: AgentLoader;assigned inside the hook (main'sshape), same forced failure — the masking reappears:
Both experiments were reverted; the committed file is byte-identical to the one
that produced the passing runs in step 1.
Note the reported budget:
Hook timed out in 120000mswith no per-hooktimeout argument anywhere in the file. That is the proof that the hooks
inherit
INTEGRATION_HOOK_TIMEOUT_MSfrom theintegrationproject rather thanfalling back to vitest's 10s default — which is what makes the deleted
HOOK_TIMEOUTconstant redundant rather than load-bearing. This probe wasre-run against the final revision specifically to establish it.
4. Confirming the inherited per-test budget. After removing the per-test
third arguments, a temporary
await new Promise((r) => setTimeout(r, 45000));at the top of the discoverytest made the runner name the inherited budget explicitly:
60000ms is
INTEGRATION_TEST_TIMEOUT_MS— not vitest's 5s default and not theold 40s. The probe was removed.
5. Neighbouring integration suites.
tests/integration/build_setup/build_setup_test.ts— passes(
20 passed | 4 skipped (24), 529.62s), re-run after the helpers moved intothe shared module it also imports.| 4 skipped (24)
, 532.91s).tests/integration/agent_loader/agent_dirname_test.ts— fails on this machine withError: Hook timed out in 40000ms`, its own pre-existing budget. Thatfile is not in this diff and is the out-of-scope sibling noted above; the
failure is identical with and without this change.
6. Repository gates, on the exact commit pushed:
No suppressions were added:
git diff fork/main -U0 | grep -E '@ts-expect-error|@ts-ignore|eslint-disable|as any|as never|: any'returnsnothing. The one
console.warnis the mechanism that makes a swallowed teardownfailure visible, matching existing practice in this directory
(
build_setup_test.tsusesconsole.errorin the same position); there is noconsole.login the diff and nono-consolerule ineslint.config.js.git diff fork/main -w -- tests/integration/app_loader/app_loader_test.ts | grep 'expect('returns nothing — no assertion line was added or removed, onlyre-indented by Prettier when the third
it()argument went away.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Windows is the leg that exercises the
maxRetries/retryDelaypath inremoveInstallArtifacts; it is covered by thevalidationmatrix rather thanlocally.
CI on this branch — all three legs reached a terminal state and passed:
run-tests (ubuntu-latest)run-tests (macos-latest)run-tests (windows-latest)run-testsmacos-latestis the leg the flake was reported on, andwindows-latest— theleg that fail-fast kept cancelling, and the only one that exercises the
maxRetries/retryDelayretry path — ran to completion and passed.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.