-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathvitest.shared.mts
More file actions
333 lines (321 loc) · 14.8 KB
/
Copy pathvitest.shared.mts
File metadata and controls
333 lines (321 loc) · 14.8 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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/**
* The two things every Vitest project in this repo shares.
*
* 1. Resolve aliases and dedupe pins, for `clients/web` and the node clients
* (cli, tui). Pass each client's directory so bare-module pins resolve
* against that client's node_modules.
* 2. The wall-clock budgets below (`TIMEOUTS` / `INTEGRATION_TIMEOUTS`), which
* every one of the six projects spreads in — `clients/launcher` included,
* which imports this module for them and nothing else.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Wall-clock budgets shared by every Vitest project in this repo (#2323).
*
* These are values somebody chose. Before this existed, three of the six
* projects ran on Vitest's own `testTimeout: 5000` and five on its
* `hookTimeout`/`teardownTimeout: 10000` — numbers sized for an idle machine,
* not for the one this team works on (8 logical cores, three or four
* concurrent agent sessions in separate worktrees each free to run the full
* `npm run local:gate`, sustained load averages of 50-70). A correct,
* deterministic test cut off mid-flight by an unchosen budget fails a gate it
* did not break, which trains people to re-run rather than read.
*
* This is NOT a licence to hide races. #1596 settled that stance and every fix
* it produced stands; what a raised ceiling buys is only that a test which
* *would* have passed is allowed to finish. A poll or a `waitFor` exits the
* instant its predicate holds, so a passing run pays nothing for the headroom —
* only a genuinely hung test spends the whole budget, and 15s over 342 files is
* still a diagnosis measured in seconds.
*
* One object rather than six hand-written triples, because six independent
* answers to the same question is exactly how five of the projects came to have
* no answer at all. `retry` is deliberately absent and must stay unset: a retry
* converts a load-induced red into a silent green on the only pre-push gate
* this repo has. `scripts/verify-test-timeouts.mjs` enforces both halves.
*/
/**
* Absolute path to the setup file every project loads, which asserts at runtime
* that no test declares a `retry` (#2323). Exported from here so the six
* projects name one path rather than six copies of a relative one.
*/
export const NO_RETRY_SETUP = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"vitest.setup.shared.mts",
);
export const TIMEOUTS = Object.freeze({
/**
* 3x Vitest's default. Covers the measured load; past this a genuinely hung
* unit test costs the whole budget to discover. Both projects that had
* already answered this question by hand — `clients/cli` and web's
* `storybook` (#2292) — independently arrived at 15000, which is why it is
* the shared value rather than a new one.
*/
testTimeout: 15_000,
/**
* Hooks do the setup and teardown a test's own budget never covers —
* spawning servers, provisioning temp dirs, unlinking filesystem-backed
* storage. They are also where the real-transitions auto-settle in
* `clients/web/src/test/renderWithMantine.tsx` awaits, so this is the one
* budget in the repo that actually governs a deliberate wait.
*/
hookTimeout: 30_000,
teardownTimeout: 30_000,
});
/**
* Web's `integration` project: same hook budgets, a longer per-test one. These
* suites spawn real HTTP/stdio servers, bind sockets and run end-to-end OAuth
* flows, so 30s is the work rather than the slack — it predates this change
* (matching the v1.5 `core/vitest.config.ts`) and is carried forward unchanged.
*/
export const INTEGRATION_TIMEOUTS = Object.freeze({
...TIMEOUTS,
testTimeout: 30_000,
});
/**
* `maxWorkers` is deliberately unset, here and in every project (#2336).
*
* The five forks-pool projects — web `unit` and `integration`, `cli`, `tui`,
* `launcher` — therefore inherit Vitest's non-watch default,
* `max(availableParallelism() - 1, 1)`: 7 on the eight-logical-core M3 this
* team works on. Web's `storybook` project is the exception: it runs the
* browser pool, whose default is a different expression,
* `max(min(12, availableParallelism() - 1), 1)` — also 7 here, but capped on
* larger machines because the main thread chokes past ~12 browser workers
* (vitest#7871) — and a single worker unless the run is headless, file
* parallelism is on, and the provider supports it. A
* `maxWorkers` set on that project *would* apply to it (`getThreadsCount`
* reads it before falling back), so it is not exempt from this decision;
* but nothing below measured it. The numbers are the forks pool's, and a cap
* for Storybook would need its own measurement.
*
* Keeping the default is a choice, not an omission, and this is the
* measurement it rests on. On `v2/main` @ `f16a51d4` — after the gate lease
* (#2339) — with the arms interleaved (default / `--maxWorkers=4` / default),
* three rounds, one worktree, nothing else of ours running:
*
* web `unit`, 345 files default 54.9s wall 378s CPU
* 4 62.4s wall 294s CPU +14% wall, -22% CPU
* web `test:coverage`, 422 default 88.0s wall 461s CPU
* 4 122.5s wall 376s CPU +39% wall, -18% CPU
* failures across all 18 runs 0
*
* So a cap is a permanent solo-run cost — ~35s on every `coverage:web`, a
* tenth of the whole gate — bought against a flake that did not occur once
* at the baseline every other #2338 number is tuned to. The CPU a cap gives
* back matters only when something else wants the cycles, and since #2339
* that is no longer another gate: gates serialize under
* `scripts/gate-lease.mjs`. What remains is non-gate work in a sibling
* session. Measured against a concurrent bare `vitest run --project=unit`
* in the same worktree (5 / 3 / 3 pairings): with the sibling at the
* default, capping our run cost us +8% and gave the sibling 14% back; with
* both capped, both sides ran at ~100s — the same total throughput as both
* at the default (99s + 92s), because eight cores are saturated either way.
* The one thing a cap moved was failures: two of the ten 7-vs-7 runs lost
* `AppRenderer.test.tsx`'s theme-flip case to its 5s inner `waitFor` at 14
* workers; none of the twelve runs with a cap on either side did. That is a
* single test's inner budget starving under two sessions' worth of workers
* — #2338's aspect 3, fixable at that site — not a case for taxing every
* solo run.
*
* Why not cap only locally, via an env var this file reads and `local:gate`
* sets? CI never runs `local:gate`, so CI would pay nothing — but every
* budget above is wall-clock, so a purely scheduling difference between the
* local gate and CI can flip an outcome, and the gate's whole promise is
* that passing it here means CI passes. One configuration in both places is
* the property worth keeping. CI's runners resolve the same expression
* against their own core count, so leaving it unset costs CI nothing.
*
* Reopen this if the lease goes away, or if `Test timed out` recurs on a run
* whose only contention is a sibling session — the 4-vs-4 figures above are
* the starting point. Whatever is committed then is a *chosen* number: macOS
* exposes no CPU affinity API, so no cap pins a worker to a performance core
* and none is "one per P-core".
*/
export function vitestSharedPaths(clientDir: string) {
const dirname = path.resolve(clientDir);
const repoRoot = path.resolve(dirname, "../..");
const sharedAliases = {
"@inspector/core": path.resolve(repoRoot, "core"),
"@modelcontextprotocol/inspector-test-server": path.resolve(
repoRoot,
"test-servers/build/index.js",
),
};
const sharedDedupe = [
"react",
"react-dom",
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
// Every SDK schema is a zod type, so a second copy breaks `instanceof`
// across the whole surface. The alias below picks the install; this keeps
// it to one copy within it.
"zod",
];
const nodeModulesAliases = [
{
find: /^react$/,
replacement: path.resolve(dirname, "node_modules/react"),
},
{
find: /^react\/jsx-runtime$/,
replacement: path.resolve(dirname, "node_modules/react/jsx-runtime.js"),
},
{
find: /^react\/jsx-dev-runtime$/,
replacement: path.resolve(
dirname,
"node_modules/react/jsx-dev-runtime.js",
),
},
{
find: /^react-dom$/,
replacement: path.resolve(dirname, "node_modules/react-dom"),
},
{
find: /^react-dom\/client$/,
replacement: path.resolve(dirname, "node_modules/react-dom/client.js"),
},
// Everything below is **root-owned** and resolves from the repo root,
// unlike the `react` / `react-dom` pins above. What they have in common is
// that no client declares them, so the root install is the only place a
// client's resolution chain is guaranteed to find one — not that they are
// all root `dependencies`: `express` is test-only and sits in the root
// `devDependencies`. Nor is this the complete root runtime set; a package
// no client install carries a copy of (`ajv`, `commander`, `undici`)
// resolves to the root on its own and needs no pin. Absence from this list
// is a statement about the installed tree, not about the manifests — check
// the tree before adding or removing an entry.
//
// `express` is reached only through `test-servers/src` (the http/oauth
// servers), which is root-owned code with no manifest of its own.
//
// `yaml` was too — `load-config.ts` — but is now also a `core/` runtime
// import: `core/mcp/skillFile.ts` parses a served SKILL.md's frontmatter
// for the SEP-2640 cross-check (#2248). That matters to anyone revisiting
// this pin: it is no longer removable by retiring a test-server path, and
// as a dependency `core/` imports it is additionally named in all three
// bundler `external` lists.
//
// Pointing these at `<client>/node_modules` is what broke when the MCP
// packages moved to the root (#1970): express was never declared by a client
// at all, it arrived in `clients/cli` as a peer of `express-rate-limit`
// under `@modelcontextprotocol/server-legacy`, so removing that manifest
// entry took express with it and every cli test that spawns a test server
// failed to resolve it.
{
find: /^express$/,
replacement: path.resolve(repoRoot, "node_modules/express"),
},
{
find: /^yaml$/,
replacement: path.resolve(repoRoot, "node_modules/yaml"),
},
// Same reasoning, one layer in: `proper-lockfile` is reached only through
// `core/` (the secrets file's cross-process lock, #2082), which is the
// other root-owned tree with no manifest of its own. Resolution finds the
// root copy on its own today — nothing declares it in a client — and this
// pin is what keeps that from depending on nothing ever arriving as some
// client's transitive dependency, which would otherwise give a test two
// copies of a module whose whole job is a single registry of held locks.
{
find: /^proper-lockfile$/,
replacement: path.resolve(repoRoot, "node_modules/proper-lockfile"),
},
// The rest of the root-owned aliases, consolidated into the root manifest
// by #2195. Mostly `core/`'s runtime dependencies, but not only —
// `@hono/node-server` is reached from web client code alone and is here for
// the same resolution reason rather than because `core/` imports it. Each
// used to be declared by the clients that reached it and was pinned to
// `<client>/node_modules` accordingly; once the declarations went away
// those paths stopped existing, so the pin has to follow the package to
// the root. Left un-repointed they would resolve
// to a directory that is not there — or, worse, to a transitive copy some
// unrelated dependency happened to drag in, which is the duplicate this
// whole pin list exists to prevent.
{
find: /^pino$/,
replacement: path.resolve(repoRoot, "node_modules/pino"),
},
{
find: /^pino\/browser\.js$/,
replacement: path.resolve(repoRoot, "node_modules/pino/browser.js"),
},
{
find: /^hono$/,
replacement: path.resolve(repoRoot, "node_modules/hono/dist/index.js"),
},
{
find: /^hono\/streaming$/,
replacement: path.resolve(
repoRoot,
"node_modules/hono/dist/helper/streaming/index.js",
),
},
{
find: /^@hono\/node-server$/,
replacement: path.resolve(repoRoot, "node_modules/@hono/node-server"),
},
{
find: /^atomically$/,
replacement: path.resolve(repoRoot, "node_modules/atomically"),
},
{
find: /^chokidar$/,
replacement: path.resolve(repoRoot, "node_modules/chokidar"),
},
{
find: /^@napi-rs\/keyring$/,
replacement: path.resolve(repoRoot, "node_modules/@napi-rs/keyring"),
},
// `zod` and `open` are pinned for a different reason from everything above:
// they resolve *somewhere* without help, and the somewhere is wrong. Both
// still sit at the top level of `clients/web/node_modules` as transitive
// copies — zod under `eslint-plugin-react-hooks`, open under Storybook —
// so an unpinned bare import from a web test resolves the client copy while
// `core/` and the SDK packages resolve the root's (Copilot).
//
// For `open` that is merely wasteful. For `zod` it is the hazard this file
// exists for: two copies in one process means a schema built by one and an
// `instanceof` check made by the other, across the entire
// `@modelcontextprotocol/*` surface. The versions are identical today and
// `verify:dep-lockstep` is what keeps them that way, but identical is not
// the same as single, and only a pin makes it single.
//
// `zod/v4` needs its own entry — first-party code imports both specifiers,
// and pinning only the bare one would split the package across two installs
// rather than collapse it.
{ find: /^zod$/, replacement: path.resolve(repoRoot, "node_modules/zod") },
{
find: /^zod\/v4$/,
replacement: path.resolve(repoRoot, "node_modules/zod/v4/index.js"),
},
{
find: /^open$/,
replacement: path.resolve(repoRoot, "node_modules/open"),
},
];
const projectResolve = {
alias: [
...Object.entries(sharedAliases).map(([find, replacement]) => ({
find,
replacement,
})),
...nodeModulesAliases,
],
dedupe: sharedDedupe,
};
return {
repoRoot,
sharedAliases,
sharedDedupe,
nodeModulesAliases,
projectResolve,
};
}
/** Convenience for importers that only have import.meta.url. */
export function vitestSharedPathsFromMetaUrl(metaUrl: string) {
const clientDir = path.dirname(fileURLToPath(metaUrl));
return vitestSharedPaths(clientDir);
}