Fix: bill app_loader discovery fixture compilation to beforeAll instead of the first test - #560
Closed
AmaadMartin wants to merge 2 commits into
Closed
Fix: bill app_loader discovery fixture compilation to beforeAll instead of the first test#560AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 2, 2026 21:03
The two discovery tests intermittently timed out at 40s on the macOS and Windows CI runners. The cost was in the first test body, not the setup: AgentLoader loads lazily, so listApps() triggered an esbuild bundle+minify of all four discovered entrypoints (measured at 16710ms on an idle Linux workstation). When that test was aborted, agentsAlreadyPreloaded stayed false, so the second test re-entered a full preload and blew the same budget, while the third hit the warm cache and passed - the observed 2-failed/1-passed signature. Warm the loader in beforeAll so the bundling is charged to the project's 120s hookTimeout and the bodies only assert on cached results, and drop the per-file 40s override so the file inherits testTimeout 60000 and hookTimeout 120000 from the integration project. The override also capped the four fixture install hooks at 40s, below the ~70s cold install already measured in build_setup_test.ts. Also guard the discovery teardown with optional chaining: loader is only assigned inside beforeAll, so a failed setup reported a TypeError from afterAll instead of the real error.
The previous revision guarded afterAll with `loader?.disposeAll()`, but `loader` was declared `AgentLoader`, so the optional chaining hedged against a state the type says cannot happen - a type lie rather than a fix. Construct the AgentLoader at describe scope instead. The constructor only registers process exit handlers and touches no filesystem (watchForChanges defaults to false), and disposeAll() is safe on a loader that never loaded anything: watcher?.close() no-ops and Promise.all runs over an empty map. So a failed install can no longer leave afterAll dereferencing an unassigned loader, the masking TypeError is impossible by construction rather than by hedging, and the type stays honest with no guard at all.
This was referenced Aug 3, 2026
Open
Owner
Author
7 tasks
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
Related: Fix: set project-wide integration hookTimeout/testTimeout in vitest.config.ts google/adk-js#548
Related: Fix: give build_setup integration hooks an explicit timeout to stop CI flakiness google/adk-js#549
Problem: Two tests in
tests/integration/app_loader/app_loader_test.tsintermittently fail on themacos-latestandwindows-latestrunners withError: Test timed out in 40000ms.(ubuntu-latestgreen, and a re-run of the identical commit green on all three — a timing flake, not a correctness bug):The cost is in the first test body, not in setup.
AgentLoaderloads lazily: the discoverybeforeAllonly rannpm installandnew AgentLoader(projectPath), and that constructor (dev/src/utils/agent_loader.ts) just registers exit listeners. The first test'sloader.listApps()then enteredpreloadAgents(), which for each of the four discovered entries in thediscoveryfixture (service_alpha/app.ts,service_beta/agent.ts,standalone_agent.ts,standalone_app.ts) runs a fullesbuild.buildwithbundle: true, minify: true, packages: 'bundle'— bundling the whole@google/adkgraph four times inside one 40s test body. The reported string wasTest timed out, notHook timed out, confirming the install hook completed and the body overran.Why exactly two failures and not three.
agentsAlreadyPreloadedis set only after thePromise.allinpreloadAgents()resolves. When test 1 is aborted by the timeout the in-flight preload keeps running but the flag is stillfalse, so test 2'sgetAppFile('service_alpha')re-enterspreloadAgents()and starts a second full four-file bundle concurrently with the first, blowing the same budget. By the time test 3 runs one preload has settled, the flag is set, and it hits theAgentFile.load()cache and finishes in ~1ms. That cascade is why a plain timeout bump treats the symptom: the work would still be billed to a test body, and one slow bundle would still take a second test down with it.Solution: three hunks, one test file, no production code.
const TEST_EXECUTION_TIMEOUT = 40000and all eight of its uses (threeit()s, fourbeforeAll/afterAlls in the CLI suites, and the discovery hooks) are removed, so the file inheritstestTimeout: 60000/hookTimeout: 120000from theintegrationproject invitest.config.ts. This is an alignment, not a bump:vitest.config.tsalready documents those budgets and notes that per-fileit()/hook timeouts still override them, and this file — added by Feat: Support apps google/adk-js#489 — never picked up the defaults that Fix: set project-wide integration hookTimeout/testTimeout in vitest.config.ts google/adk-js#548 and Fix: give build_setup integration hooks an explicit timeout to stop CI flakiness google/adk-js#549 established. The override was also capping the four fixturenpm installhooks at 40s, below the ~70s cold, network-bound install already measured and recorded intests/integration/build_setup/build_setup_test.ts. Those install hooks are exactly what the project's 120shookTimeoutexists for.await loader.preloadAgents()is added to the discoverybeforeAll, so the four esbuild passes are charged to the hook budget and the test bodies only assert on cached results.preloadAgents()is a public method already called directly bydev/test/utils/agent_loader_test.ts; no visibility was widened.let loader: AgentLoaderassigned insidebeforeAllmeant a failed setup leftafterAlldereferencingundefinedand reporting aTypeErrorthat hid the real error. The loader is now constructed atdescribescope (const loader = new AgentLoader(projectPath)). That is safe because the constructor only registers process exit handlers and touches no filesystem (watchForChangesdefaults tofalse), anddisposeAll()is a no-op on a loader that never loaded anything (watcher?.close()with an undefined watcher,Promise.allover an empty map).afterAllkeeps its originalawait loader.disposeAll()unchanged. No.catch(() => {})was added — swallowing a real dispose error would hide temp-file leaks. An earlier revision guarded the teardown withloader?.disposeAll()instead; that was the wrong fix, becauseloaderis typedAgentLoaderand the optional chaining hedged against a state the type declares impossible. Removing the failure mode by construction needs no guard and keeps the type honest.The diff is one file. No assertion is added, removed, weakened or reworded, and no
describe/itname changes (CI history and flake tracking key off them). The apparent size of the diff is Prettier reflowingit(name, fn, TIMEOUT)intoit(name, fn)once the trailing timeout argument is gone.Measured before/after (
--reporter=verbose, same machine, cold fixture in every run):beforeAllshould discover apps vs agents across directories and standalone filesshould load App from directory entrypoint and expose App and rootAgentshould synthesize App when loadApp() is called on BaseAgent file16710 ms is on an idle Linux workstation; the macOS/Windows runners execute this while
npm run test:coveragedrives four vitest projects with v8 coverage concurrently, which is what pushes the same work past 40s there.Running the file unmodified on this machine also reproduced the latent install-hook half of the bug directly, which is the clearest evidence that the 40s cap — not the runner — was the problem: all four
beforeAlls failed withError: Hook timed out in 40000ms., and the discovery teardown then threw the maskingTypeError: Cannot read properties of undefined (reading 'disposeAll')on top of it. After the change the same four hooks complete inside the 120s budget.Honest trade-off: a genuinely hung hook now takes up to 120s to surface instead of 40s, mirroring the note already recorded in
tests/integration/build_setup/build_setup_test.ts. That is the cost of not reporting a slow-but-working cold install as a failure.Collision check (required before implementing;
gh pr list --state open --limit 1000): this area is crowded. Several open PRs overlap — most notably one that removes the same per-file override and adds the samepreloadAgents()warm-up across the integration suites plusvitest.config.ts, and three that add the warm-up alone. This PR is the self-contained, single-file version scoped strictly toapp_loader_test.ts, and it is the only one that also fixes theafterAllTypeErrorwith optional chaining. If a broader budget-alignment PR lands first, close this one as a duplicate — the two are not meant to be stacked.Out of scope (deliberately not done here): de-duplicating concurrent
preloadAgents()callers indev/src/utils/agent_loader.ts(a real improvement, but a production change), and the identical 40s install-hook override intests/integration/agent_loader/agent_dirname_test.tsandtests/integration/tools/run_skill_script_tool_test.ts.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.
Coverage: this change adds no production lines, so there is no new production code to cover — the coverage bar is satisfied vacuously and does not regress. No new test was added either: the task is to stop five existing tests from flaking while preserving every existing assertion byte-for-byte.
Unit Tests:
[x] I have added or updated unit tests for my change. — N/A, see above; the existing
AgentLoaderunit suite is untouched and confirmed green:npx vitest run --project unit:dev dev/test/utils/agent_loader_test.ts→ 30 passed.[x] All unit tests pass locally.
Targeted commands run (no full-repo suite):
Falsification proof 1 — the work really left the test bodies (hunks 1-2). With the fix in place, a deliberately tiny 2000 ms per-test budget was passed to the two previously-failing tests: both passed (4 ms and 1 ms). The same 2000 ms budget with only
await loader.preloadAgents()removed — mutating the exact line the fix adds — failed, and reproduced the CI signature precisely (2 failed / 1 passed, third test green off the cache):A timeout bump alone cannot pass this check. Both temporary edits were reverted;
grep -n "40000\|2000" tests/integration/app_loader/app_loader_test.tsreturns nothing.Falsification proof 2 — the non-nullable loader is load-bearing (hunk 3). With
throw new Error('boom')injected as the first statement of the discoverybeforeAll:describescope) — the run reports only the real error,Error: boom.let loader: AgentLoaderassigned insidebeforeAll), same injected throw — the misleading second error comes back on top of it:TypeError: Cannot read properties of undefined (reading 'disposeAll').Both temporary edits were reverted. This failure mode was also observed unprompted: running the file on unmodified
mainon this machine produced exactly thatTypeErrorafter the hook blew its 40s budget.Repeat-run stability. The file was run three times consecutively and was green each time —
Tests 6 passed (6)on every run, with the previously-failing discovery test reporting 3 ms, 3 ms and 4 ms. This matters because eachafterAlldeletes its fixture'snode_modulesandpackage-lock.json, so residue from a previous run would surface on the second.Manual End-to-End (E2E) Tests:
Confirm from the verbose output that the discovery
beforeAllcarries the multi-second setup and that all three discovery tests report in the low milliseconds. To approximate the loaded macOS/Windows runners, re-run under CPU contention (theunit:devsuite above was run concurrently with the stability runs); the file stayed green.CI result.
run-testsis green on all three matrix legs —ubuntu-latest(3m55s),macos-latest(4m22s) andwindows-latest(8m57s) — i.e. on both runners where this flake was reported. In the Windows leg the file under change reported✓ integration tests/integration/app_loader/app_loader_test.ts (6 tests) 76518ms.Reported honestly: the first Windows attempt was red, but not on anything in this diff. Its single failure was
unit:core core/test/code_executors/unsafe_local_code_executor_test.ts:161 > UnsafeLocalCodeExecutor > should execute shell code and return stdout — Error: Test timed out in 5000ms.(1 failed | 2679 passed), a real-subprocess unit test running under vitest's default 5s budget, in a file this PR does not touch.app_loader_test.tspassed on that attempt too. Re-running the job passed, confirming it as an unrelated Windows flake. It is deliberately not fixed here — that is a separate file with its own in-flight fixes, and folding it in would widen this diff beyond the one test file.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.