angular / angular/angular-cli

`@angular/build:unit-test` virtual `init-testbed.js` guards `initTestEnvironment()` behind a once-per-worker symbol → stale DomAdapter under vitest ≥4.0.5 + `isolate: false`

Aperta
#33,047 0 commenti 1 reazione 0 assegnatari Vedi su GitHub
angular/build:unit-test area: @angular/build gemini-triaged
Lingua principale
TypeScript
Stelle
27k
Fork
11.8k
Merge medio
14h 23m
PR unite (30g)
162

Descrizione

> This is a follow-up to the now-closed/auto-locked [#32754](https://github.com/angular/angular-cli/issues/32754) with the missing details. That issue was dismissed as potentially analog-specific because the reporter had `@analogjs/vitest-angular` in their deps. This report uses **`@angular/build:unit-test` only** and pins the bug to specific lines in `@angular/build`'s own source.

### Which `@angular/*` package(s) are the source of the bug?

`@angular/build`

### Is this a regression?

Yes — surfaces with vitest ≥4.0.5 (see vitest-dev/vitest#8944).

### Description

`@angular/build:unit-test`'s vitest runner combines two defaults that interact badly under vitest ≥4.0.5:

- [`plugins.ts:254`](https://github.com/angular/angular-cli/blob/main/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts#L254) hardcodes `isolate: false` for the vitest pool (intentional, "align with the Karma/Jasmine experience"). Worker module graphs are therefore reused across spec files.
- [`build-options.ts:73-89`](https://github.com/angular/angular-cli/blob/main/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts#L73) — the injected virtual `init-testbed.js` wraps `getTestBed().initTestEnvironment(...)` in an `if (!globalThis[ANGULAR_TESTBED_SETUP])` guard. The comment on line 79 explicitly says *"the guard condition above ensures that the setup is only performed once"*. That's the anti-pattern.

The first spec file in a worker initializes a `platformBrowserTesting` whose `DomAdapter` captures the jsdom `document` as a closure reference. Subsequent spec files in the same worker skip the init block entirely, so the `DomAdapter` keeps being reused. When jsdom's document swaps between spec files — and vitest ≥4.0.5 no longer re-executes setup files between spec files under `isolate: false` (vitest-dev/vitest#8944) — `_getDOM().getDefaultDocument().createElement(tagName)` returns something that is not a real `HTMLElement`, and `DOMTestComponentRenderer.insertRootElement` crashes:

\`\`\`
TypeError: rootElement.setAttribute is not a function
at DOMTestComponentRenderer.insertRootElement (@angular/platform-browser/testing)
at _TestBedImpl.createComponent
\`\`\`

Different spec files fail each run; each failing spec passes in isolation. Classic test-isolation bug.

The analog project had the analogous pattern in `setupTestBed()` and fixed it in [analogjs/analog#2244](https://github.com/analogjs/analog/pull/2244) by calling `resetTestEnvironment()` + `initTestEnvironment()` on every setup invocation instead of guarding with a once-only singleton.

### Reproduction

I can put up a public minimal repro if helpful, but the bug is visible from the builder source alone — the conditions are:

- Angular 22 (or 21 with vitest ≥4.0.5) monorepo using `@angular/build:unit-test` with jsdom.
- `runnerConfig` unset, so the builder's default `isolate: false` applies.
- Suite of ~50+ spec files to make the race frequent.
- Run `ng test --force` (or equivalent) several times. A different 1–10 specs crash each run with the `setAttribute` trace.

Confirmed environment:

\`\`\`
Angular CLI: 22.0.0-next.6
@angular/build: 22.0.0-next.6
@angular/core: 22.0.0-next.9
vitest: 4.1.4 (via ^4.0.17)
Environment: jsdom
Runtime: Node 22 / Bun 1.x
OS: Windows 11 (also reported on Ubuntu CI via analogjs/analog#2222)
\`\`\`

### Exception

\`\`\`
TypeError: rootElement.setAttribute is not a function
❯ DOMTestComponentRenderer.insertRootElement node_modules/@angular/platform-browser/fesm2022/testing.mjs:24
❯ _TestBedImpl.createComponent packages/core/testing/src/test_bed.ts:420
\`\`\`

(The #32754 variant `Cannot set base providers because it has already been called` is the same root cause but a different downstream symptom — it fires when the user's own `test-setup.ts` re-calls `initTestEnvironment`. Projects that don't re-call it land on `setAttribute is not a function` instead.)

### Proposed fix

**Primary** — mirror [analogjs/analog#2244](https://github.com/analogjs/analog/pull/2244). Replace the `if (!globalThis[ANGULAR_TESTBED_SETUP])` guard in `build-options.ts:73-89` with a reset-and-reinit pattern:

\`\`\`ts
getTestBed().resetTestEnvironment();
getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
// ...
});
\`\`\`

Even under `isolate: false`, if the setup file re-runs (or a user hook calls it), each spec file gets a fresh platform targeting the current jsdom.

**Secondary (defensive)** — either flip the default to `isolate: true` with a documented opt-out for projects that want the Karma-style speed, or make `DOMTestComponentRenderer.insertRootElement` throw a clearer error when `rootElement.setAttribute` is not callable (e.g. *"TestBed's DOM adapter is referencing a document that has been torn down — check your vitest \`isolate\` setting"*). Today the `TypeError` has no breadcrumb to the root cause.

**Docs** — the unit-test builder docs should warn that `isolate: false` + vitest ≥4.0.5 + jsdom silently bleeds DOM state across spec files.

### Local workaround

Override to `isolate: true` via a project-level `vitest-base.config.ts` (possible because `runnerConfig: true` merges user config on top of the builder defaults). 10 consecutive `ng test --force` runs then pass deterministically. Wall-time cost is ~10–30%. This is a workaround, not a fix — the builder's guard is what should change.

### References

- [vitest-dev/vitest#8944](https://github.com/vitest-dev/vitest/issues/8944) — vitest 4.0.5 regression that triggers this class of flake.
- [angular/angular-cli#32754](https://github.com/angular/angular-cli/issues/32754) — closed/auto-locked predecessor of this report. Same root cause, different surface symptom, closed as "insufficient information."
- [analogjs/analog#2222](https://github.com/analogjs/analog/issues/2222) — same error stack downstream in analog's vitest-angular stack.
- [analogjs/analog#2244](https://github.com/analogjs/analog/pull/2244) — reference fix pattern.

Guida per i contributori

Apri la guida per i contributori

Direzione di ricerca

Inizia da packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts e plugins.ts, quindi esegui ripetutamente la suite jsdom segnalata con ng test --force, isolate: false e vitest 4.x. Verifica che l'errore stale-DomAdapter non si verifichi più tra i file spec, preservando il comportamento di configurazione degli unit-test del builder; l'issue propone il pattern reset-and-reinitialize di analogjs/analog#2244 e indica la documentazione come attività di follow-up secondaria.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
angular, typescript
Ambito
build-system, testing-qa
Tipo di issue
Bug
Difficoltà
4/5
Tempo stimato
3-5 giorni
Stato di attività
Tranquilla
Chiarezza
Specificata chiaramente
Idoneità per principianti
55/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.