Repository navigation
Expand file tree
/
Copy pathvitest.setup.shared-global-leak.ts
More file actions
135 lines (126 loc) · 5.68 KB
/
Copy pathvitest.setup.shared-global-leak.ts
File metadata and controls
135 lines (126 loc) · 5.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
/**
* ObjectUI — shared-global leak detection (objectui#8500)
*
* The logic half of `vitest.setup.shared-global-leak-guard.ts`, which is the
* SETUP FILE that arms it. Split for the same reason `vitest.setup.i18n-global.ts`
* is split out of `vitest.setup.base.ts`, plus one specific to this guard:
*
* `scripts/__tests__/unit-registry-absence-collision.test.ts` executes the unit
* project's import closures in a private module graph, so ANY module a test file
* names as an import specifier is re-executed there. A module whose body calls
* `afterAll` would register a stray hook in the middle of that gate's `beforeAll`.
* This file's body registers nothing and touches no global, so its consumers —
* that gate included — can import it freely; the arming happens only where
* `installSharedGlobalLeakGuard()` is CALLED, which is the setup file alone.
*
* Why the guard exists at all, and why an `afterAll` in a setup file is the only
* place it can live, is written out in that setup file's header.
*/
import { afterAll } from 'vitest';
/**
* The globals the root setup graph installs, and that a later file therefore
* inherits rather than builds.
*
* `fetch` is `vitest.setup.network-escape-guard.ts`'s recording wrapper. The
* three below it are `vitest.setup.base.ts`'s: the `Storage` pair it rebuilds
* per file (objectui#7271) and the `ResizeObserver` polyfill Radix reaches for.
*
* Deliberately a NAMED list rather than a diff of every own property of
* `globalThis`: a test that installs a global of its own is not the defect here,
* and a guard that reds on those would be turned off within the week.
*/
export const WATCHED_GLOBALS = [
'fetch',
'localStorage',
'sessionStorage',
'ResizeObserver',
] as const;
/** What `globalThis` held for each watched name when the file started. */
export type GlobalBaseline = ReadonlyArray<{ name: string; value: unknown }>;
/** One watched global that a file replaced and did not put back. */
export type GlobalLeak = { name: string; expected: string; found: string };
/** A short, comparable rendering of a global's value for the failure message. */
export function describeGlobal(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'function') return `function ${value.name === '' ? '<anonymous>' : value.name}`;
if (typeof value === 'object') {
const ctor = (value as { constructor?: { name?: string } }).constructor;
return `${ctor?.name ?? 'Object'} instance`;
}
return typeof value;
}
/** Read the current value of each watched global out of `scope`. */
export function snapshotGlobals(
scope: Record<string, unknown>,
names: ReadonlyArray<string> = WATCHED_GLOBALS,
): GlobalBaseline {
return names.map((name) => ({ name, value: scope[name] }));
}
/**
* Which watched globals no longer hold the value they held at `baseline`.
*
* Identity, not shape: a double that behaves like `fetch` is still not the
* wrapper the run is guarded by, and "looks close enough" is how a safety guard
* gets silently replaced.
*/
export function detectGlobalLeaks(
scope: Record<string, unknown>,
baseline: GlobalBaseline,
): GlobalLeak[] {
const leaks: GlobalLeak[] = [];
for (const entry of baseline) {
if (scope[entry.name] === entry.value) continue;
leaks.push({
name: entry.name,
expected: describeGlobal(entry.value),
found: describeGlobal(scope[entry.name]),
});
}
return leaks;
}
/** Put every leaked global back, so the next file in this worker starts clean. */
export function healGlobalLeaks(scope: Record<string, unknown>, baseline: GlobalBaseline): void {
for (const entry of baseline) {
if (scope[entry.name] === entry.value) continue;
scope[entry.name] = entry.value;
}
}
/** The message the leaking file goes red with. */
export function formatLeakReport(leaks: ReadonlyArray<GlobalLeak>): string {
return [
`Shared-global leak: this test FILE left ${leaks.length === 1 ? 'a global' : 'globals'} replaced.`,
...leaks.map((l) => ` - globalThis.${l.name}: expected ${l.expected}, found ${l.found}`),
'',
'This project runs `isolate: false` — one global object per worker — so the',
'value above is what every LATER file in this worker would have inherited. It',
'has been put back, so this file is the only one that reds; without that, the',
'failure would have surfaced in a file that did nothing wrong, in some shards',
'and not others (objectui#8500).',
'',
'Fix: install the double with `vi.stubGlobal(name, double)` and hand it back',
'with `vi.unstubAllGlobals()` in an `afterAll`. A bare `globalThis.x = ...` /',
'`global.x = ...` assignment cannot be undone by `vi.unstubAllGlobals()` or by',
'`vi.restoreAllMocks()` — neither knows the value was ever replaced.',
'',
'If the global genuinely has to stay replaced for the whole file (a component',
'that reads after the test body returns), that is still fine: install it once',
'at module scope with `vi.stubGlobal` and unstub in `afterAll`, which runs',
'after the last test and before this check.',
].join('\n');
}
/**
* Register the per-file check. Called at module scope below; exported so the pin
* test can assert the wiring rather than infer it.
*/
export function installSharedGlobalLeakGuard(
scope: Record<string, unknown> = globalThis as unknown as Record<string, unknown>,
): void {
const baseline = snapshotGlobals(scope);
afterAll(() => {
const leaks = detectGlobalLeaks(scope, baseline);
// Heal FIRST: a throw below must not also cost the next file its globals.
healGlobalLeaks(scope, baseline);
if (leaks.length > 0) throw new Error(formatLeakReport(leaks));
});
}