test-inventory 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -1
  3. package/dist/ast.d.ts +3 -3
  4. package/dist/ast.js +4 -4
  5. package/dist/bin.js +1 -0
  6. package/dist/cli.d.ts +4 -2
  7. package/dist/cli.js +27 -7
  8. package/dist/config-dependencies.d.ts +62 -0
  9. package/dist/config-dependencies.js +231 -0
  10. package/dist/config-runs.d.ts +113 -0
  11. package/dist/config-runs.js +807 -0
  12. package/dist/config.d.ts +90 -0
  13. package/dist/config.js +652 -0
  14. package/dist/dependency-hash.d.ts +242 -0
  15. package/dist/dependency-hash.js +1458 -0
  16. package/dist/diagnostics.js +6 -1
  17. package/dist/extract.d.ts +20 -0
  18. package/dist/extract.js +20 -4
  19. package/dist/filter.d.ts +6 -0
  20. package/dist/filter.js +18 -1
  21. package/dist/frameworks/bindings.d.ts +22 -0
  22. package/dist/frameworks/bindings.js +19 -9
  23. package/dist/frameworks/common.d.ts +5 -0
  24. package/dist/frameworks/common.js +11 -0
  25. package/dist/frameworks/playwright.d.ts +2 -1
  26. package/dist/frameworks/playwright.js +11 -2
  27. package/dist/frameworks/vitest.d.ts +2 -1
  28. package/dist/frameworks/vitest.js +9 -3
  29. package/dist/hash.d.ts +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/inventory.d.ts +2 -2
  32. package/dist/inventory.js +2 -1
  33. package/dist/json.js +2 -0
  34. package/dist/model.d.ts +9 -1
  35. package/dist/options.d.ts +2 -2
  36. package/dist/records.d.ts +58 -2
  37. package/dist/records.js +196 -28
  38. package/dist/scan-file.d.ts +13 -1
  39. package/dist/scan-file.js +16 -4
  40. package/dist/scan-source.js +1 -1
  41. package/dist/scan.d.ts +2 -1
  42. package/dist/scan.js +131 -18
  43. package/dist/types.d.ts +83 -7
  44. package/dist/values.d.ts +5 -0
  45. package/dist/values.js +5 -0
  46. package/dist/vitest-config.d.ts +6 -4
  47. package/dist/vitest-config.js +32 -28
  48. package/dist/vitest-defaults.d.ts +6 -0
  49. package/dist/vitest-defaults.js +16 -0
  50. package/package.json +10 -8
  51. package/schema/v1.json +200 -6
@@ -0,0 +1,1458 @@
1
+ import path from 'node:path';
2
+ import { visitorKeys, } from 'oxc-parser';
3
+ import { childNodes, isFunctionNode, propertyKey, unwrap } from "./ast.js";
4
+ import { exportName, parseModule } from "./frameworks/bindings.js";
5
+ import { destructuredNames, problem, takesWhole } from "./frameworks/common.js";
6
+ import { shortHash, hashNodes } from "./hash.js";
7
+ import { snippet } from "./source.js";
8
+ import { bindingNames, isScopeNode, Values } from "./values.js";
9
+ /** Statements that only declare something, unless code in them runs while they are evaluated. */
10
+ const DECLARATIONS = new Set([
11
+ 'ImportDeclaration',
12
+ 'FunctionDeclaration',
13
+ 'ClassDeclaration',
14
+ 'VariableDeclaration',
15
+ 'TSTypeAliasDeclaration',
16
+ 'TSInterfaceDeclaration',
17
+ 'TSEnumDeclaration',
18
+ 'TSModuleDeclaration',
19
+ 'TSDeclareFunction',
20
+ 'TSImportEqualsDeclaration',
21
+ 'ExportAllDeclaration',
22
+ 'ExportNamedDeclaration',
23
+ 'ExportDefaultDeclaration',
24
+ 'EmptyStatement',
25
+ ]);
26
+ /** Expressions that run code other than the declaration's own: calls, constructors, decorators, static blocks. */
27
+ const RUNS = new Set([
28
+ 'CallExpression',
29
+ 'NewExpression',
30
+ 'TaggedTemplateExpression',
31
+ 'AwaitExpression',
32
+ 'ImportExpression',
33
+ 'Decorator',
34
+ 'StaticBlock',
35
+ ]);
36
+ /** The nodes an import binds a name with. */
37
+ const IMPORT_BINDINGS = new Set([
38
+ 'ImportSpecifier',
39
+ 'ImportDefaultSpecifier',
40
+ 'ImportNamespaceSpecifier',
41
+ 'TSImportEqualsDeclaration',
42
+ ]);
43
+ /** Keys that hold types, which don't run. */
44
+ const TYPE_KEYS = new Set([
45
+ 'typeAnnotation',
46
+ 'returnType',
47
+ 'typeParameters',
48
+ 'typeArguments',
49
+ 'superTypeArguments',
50
+ 'implements',
51
+ ]);
52
+ const TYPES = new Set(['TSInterfaceDeclaration', 'TSTypeAliasDeclaration']);
53
+ /** Vitest calls that load or mock a module by its path. */
54
+ const VI_MODULE_CALLS = new Set(['mock', 'doMock', 'unmock', 'doUnmock', 'importActual', 'importMock']);
55
+ /** Reasons the resolver gives for an installed package, whose code the hash leaves out on purpose. */
56
+ const PACKAGE_REASONS = new Set([
57
+ 'is an installed package, which is not followed',
58
+ 'is not a local file, a path alias or a workspace package',
59
+ ]);
60
+ /** Modules whose `test` object fixtures are defined on. */
61
+ const FRAMEWORK = /^(?:@playwright\/test|playwright\/test|@playwright\/experimental-ct-[\w-]+|vitest)$/;
62
+ /** Calls on a test object whose functions run with the test's fixtures: tests and hooks. */
63
+ const FIXTURE_CALLS = new Set([
64
+ ...['test', 'it', 'only', 'todo', 'concurrent', 'sequential'],
65
+ ...['beforeEach', 'afterEach', 'beforeAll', 'afterAll', 'aroundEach', 'aroundAll'],
66
+ ]);
67
+ /** Conditions, which take a value or a function of the fixtures, as in `test.skip(({ account }) => ...)`. */
68
+ const CONDITION_CALLS = new Set(['skip', 'fixme', 'fail', 'slow']);
69
+ /** Vitest's globals, which a file with `globals: true` calls without importing them. */
70
+ const GLOBAL_TESTS = new Set([
71
+ ...['test', 'it', 'describe', 'suite', 'bench'],
72
+ ...['beforeEach', 'afterEach', 'beforeAll', 'afterAll', 'aroundEach', 'aroundAll'],
73
+ ]);
74
+ /** Matchers that compare against a file beside the test: Vitest's snapshots and file snapshots, Playwright's too. */
75
+ const SNAPSHOT_MATCHERS = new Set([
76
+ 'toMatchSnapshot',
77
+ 'matchSnapshot',
78
+ 'toMatchScreenshot',
79
+ 'toThrowErrorMatchingSnapshot',
80
+ 'toMatchFileSnapshot',
81
+ 'toHaveScreenshot',
82
+ 'toMatchAriaSnapshot',
83
+ ]);
84
+ /**
85
+ * The name or path a snapshot matcher is given: a string, a list of path parts as Playwright takes, or an options
86
+ * object's `name`; null for anything else, as a name worked out at run time.
87
+ */
88
+ function snapshotName(argument) {
89
+ if (argument.type === 'Literal' && typeof argument.value === 'string')
90
+ return { name: argument.value, isParts: false };
91
+ if (argument.type === 'ArrayExpression') {
92
+ const parts = argument.elements.map((element) => (element ? snapshotName(element) : null));
93
+ return parts.every((part) => part !== null)
94
+ ? { name: parts.map((part) => part.name).join('/'), isParts: true }
95
+ : null;
96
+ }
97
+ if (argument.type === 'ObjectExpression') {
98
+ const property = argument.properties.find((item) => item.type === 'Property' && propertyKey(item) === 'name');
99
+ return property?.type === 'Property' ? snapshotName(property.value) : null;
100
+ }
101
+ return null;
102
+ }
103
+ /** How a matcher is called from another, as a custom matcher calls Vitest's own. */
104
+ const CALLS = new Set(['call', 'apply']);
105
+ /** The characters Playwright 1.64 turns into `-` in a snapshot name before its extension. */
106
+ const UNSAFE_NAME = /[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g;
107
+ /** Calls that set fixtures for a file or a describe: Playwright's `test.use()`, Vitest's `override()` and `scoped()`. */
108
+ const USE_CALLS = new Set(['use', 'override', 'scoped']);
109
+ /** The exports of a framework module that are test objects, or combine them. */
110
+ const TEST_EXPORTS = new Set(['test', 'it', 'mergeTests']);
111
+ /** How many constants and imports deep a fixture's value is followed. */
112
+ const MAX_VALUE_DEPTH = 16;
113
+ /** Playwright's own fixtures and options (1.64), which a fixtures file can override for every test. */
114
+ const BUILT_INS = new Set([
115
+ ...['browser', 'browserName', 'channel', 'connectOptions', 'context', 'defaultBrowserType', 'headless'],
116
+ ...['launchOptions', 'mount', 'page', 'playwright', 'request', 'reuseContext', 'router', 'screenshot', 'trace'],
117
+ 'video',
118
+ ...['acceptDownloads', 'actionTimeout', 'baseURL', 'bypassCSP', 'clientCertificates', 'colorScheme'],
119
+ ...['contextOptions', 'contrast', 'deviceScaleFactor', 'extraHTTPHeaders', 'forcedColors', 'geolocation'],
120
+ ...['hasTouch', 'httpCredentials', 'ignoreHTTPSErrors', 'isMobile', 'javaScriptEnabled', 'locale'],
121
+ ...['navigationTimeout', 'offline', 'permissions', 'proxy', 'reducedMotion', 'screen', 'serviceWorkers'],
122
+ ...['storageState', 'testIdAttribute', 'timezoneId', 'userAgent', 'viewport'],
123
+ ]);
124
+ /**
125
+ * Whether an expression is a value that can't be a function, such as a literal, an object or an array: a fixture with
126
+ * such a value is never called, so it asks for no other fixture.
127
+ */
128
+ function isPlainValue(node) {
129
+ return PLAIN_VALUES.has(node.type);
130
+ }
131
+ const PLAIN_VALUES = new Set([
132
+ 'Literal',
133
+ 'TemplateLiteral',
134
+ 'ArrayExpression',
135
+ 'ObjectExpression',
136
+ 'UnaryExpression',
137
+ 'BinaryExpression',
138
+ ]);
139
+ /** Whether a declarator at the top of a module, as an exported one is, is a `const`. */
140
+ function isConst(module, declarator) {
141
+ return module.parsed.program.body.some((statement) => {
142
+ const declaration = declared(statement);
143
+ return (declaration.type === 'VariableDeclaration' &&
144
+ declaration.kind === 'const' &&
145
+ declaration.declarations.includes(declarator));
146
+ });
147
+ }
148
+ /** A property written as `name: value` or `name() {}`, whose name is known. */
149
+ function isNamedProperty(property) {
150
+ return property.type === 'Property' && !property.computed && propertyKey(property) !== null;
151
+ }
152
+ /** `auto: true` in a fixture's options. */
153
+ function isAutoOption(property) {
154
+ return (property.type === 'Property' &&
155
+ propertyKey(property) === 'auto' &&
156
+ property.value.type === 'Literal' &&
157
+ property.value.value === true);
158
+ }
159
+ const CODE_FILE = /\.[cm]?[jt]sx?$/;
160
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/;
161
+ /**
162
+ * What an import being looked up stands for while the lookup leads back to it, through re-exports: nothing found
163
+ * there, so that an `export *` goes on to the next module, as the module system does.
164
+ */
165
+ const IN_PROGRESS = [];
166
+ const COMPUTED = "The module this loads is worked out at run time, so dependencyHash doesn't cover it.";
167
+ function isLocalSpecifier(specifier) {
168
+ return /^\.\.?(?:\/|$)/.test(specifier) || specifier.startsWith('#');
169
+ }
170
+ function contains(outer, inner) {
171
+ return outer.start <= inner.start && inner.end <= outer.end;
172
+ }
173
+ /** The path to a node from `from`, searched node by node, for a node outside its parent's span. */
174
+ function pathTo(from, target) {
175
+ if (from === target)
176
+ return [];
177
+ for (const child of childNodes(from)) {
178
+ const path = pathTo(child, target);
179
+ if (path)
180
+ return [child, ...path];
181
+ }
182
+ return null;
183
+ }
184
+ /**
185
+ * The scopes around a node, outermost first, found by going down from the program to it. Spans lead the way, except
186
+ * where a node is outside its parent's span, as a decorator written before `export` is.
187
+ */
188
+ function scopesAt(program, target) {
189
+ const scopes = [];
190
+ let node = program;
191
+ while (node !== target) {
192
+ if (isScopeNode(node))
193
+ scopes.push(node);
194
+ const next = childNodes(node).find((child) => contains(child, target));
195
+ if (!next) {
196
+ const rest = pathTo(node, target);
197
+ scopes.push(...rest.slice(0, -1).filter(isScopeNode));
198
+ break;
199
+ }
200
+ node = next;
201
+ }
202
+ return scopes;
203
+ }
204
+ /** Where a statement's own declaration is, for a statement that exports one. */
205
+ function declared(statement) {
206
+ return statement.type === 'ExportNamedDeclaration' && statement.declaration ? statement.declaration : statement;
207
+ }
208
+ /** Whether evaluating a statement runs code, leaving out the bodies of the functions it declares, which run later. */
209
+ function runsOnLoad(node) {
210
+ if (RUNS.has(node.type))
211
+ return true;
212
+ // An instance field's value is worked out when the class is built, not when it is defined.
213
+ if ((node.type === 'PropertyDefinition' || node.type === 'AccessorProperty') && !node.static) {
214
+ return node.decorators.length > 0 || (node.computed && runsOnLoad(node.key));
215
+ }
216
+ if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression')
217
+ return false;
218
+ if (node.type === 'ArrowFunctionExpression')
219
+ return false;
220
+ return childNodes(node).some(runsOnLoad);
221
+ }
222
+ /**
223
+ * Whether a top-level or describe statement runs when the file or describe does. An import that names nothing is there
224
+ * for what its module does when loaded, so it is code of the file, also for a package.
225
+ */
226
+ /** An import that loads its module when the file runs: not a type-only one. */
227
+ function isRuntimeImport(statement) {
228
+ return ((statement.type === 'ImportDeclaration' || statement.type === 'TSImportEqualsDeclaration') &&
229
+ statement.importKind !== 'type');
230
+ }
231
+ function isLoadCode(statement) {
232
+ // `import '@testing-library/jest-dom'` runs a module for what it does; a named import is followed through its names.
233
+ if (statement.type === 'ImportDeclaration')
234
+ return statement.importKind !== 'type' && statement.specifiers.length === 0;
235
+ return !DECLARATIONS.has(statement.type) || runsOnLoad(statement);
236
+ }
237
+ /**
238
+ * Follows what each test runs, across the files of one scan: its call, the code around it, and every declaration
239
+ * that code uses, in its own file and through imports. Each piece is read once per scan, however many tests reach it.
240
+ */
241
+ export class DependencyGraph {
242
+ modules = new WeakMap();
243
+ units = [new WeakMap(), new WeakMap()];
244
+ imports = new WeakMap();
245
+ snapshotFiles = new Map();
246
+ calledTests = new WeakMap();
247
+ testObjects = [
248
+ new WeakMap(),
249
+ new WeakMap(),
250
+ ];
251
+ /** Places already reported, so that one the scan reaches from many files is reported once. */
252
+ reported = new Set();
253
+ /** The module of a file the scan reads code from, given as its parsed file. */
254
+ module(parsed, relativeFilePath, values, readModule) {
255
+ let module = this.modules.get(parsed);
256
+ if (!module) {
257
+ module = this.newModule(relativeFilePath, parsed, null, values, readModule);
258
+ this.modules.set(parsed, module);
259
+ }
260
+ return module;
261
+ }
262
+ /**
263
+ * Code a test or a describe runs, written in `module`. `cut` lists the nodes left out, such as the tests in a
264
+ * describe.
265
+ */
266
+ unit(module, nodes, cut, types) {
267
+ const program = module.parsed.program;
268
+ return this.newUnit(module, nodes.map((node) => ({ node, scopes: scopesAt(program, node) })), new Map(cut.map((span) => [span.start, span.end])), types);
269
+ }
270
+ /** The code a file runs when it is loaded, leaving out the tests and describes at `cut`. */
271
+ loadUnit(module, types, cut = [], allImports = false) {
272
+ const index = Number(types);
273
+ const cached = module.load[index];
274
+ if (cached)
275
+ return cached;
276
+ const program = module.parsed.program;
277
+ const unit = this.newUnit(module, [], new Map(cut.map((span) => [span.start, span.end])), types);
278
+ // Read once the walk reaches it, as telling a test object apart may need this module's load code in turn.
279
+ // A test file's every import is its own code; another module's are followed through the names a test uses.
280
+ const isCode = (statement) => isLoadCode(statement) || (allImports && isRuntimeImport(statement));
281
+ unit.prepare = () => {
282
+ for (const statement of program.body.filter(isCode)) {
283
+ const declaration = declared(statement);
284
+ if (declaration.type !== 'VariableDeclaration') {
285
+ unit.roots.push({ node: statement, scopes: [program] });
286
+ continue;
287
+ }
288
+ // A test object's fixtures run only for the tests that ask for them.
289
+ for (const declarator of declaration.declarations) {
290
+ const testObject = this.testObjectUnit(module, declarator, types);
291
+ if (testObject)
292
+ unit.with.push(testObject);
293
+ else if (runsOnLoad(declarator))
294
+ unit.roots.push({ node: declarator, scopes: [program] });
295
+ }
296
+ }
297
+ };
298
+ module.load[index] = unit;
299
+ return unit;
300
+ }
301
+ /** The units a set of roots reaches, leaving out those `skip` reached, with their hashes by file. */
302
+ reach(roots, skip = []) {
303
+ const units = new Set();
304
+ const pending = [...roots];
305
+ for (let unit = pending.pop(); unit; unit = pending.pop()) {
306
+ if (units.has(unit) || skip.some((reached) => reached.units.has(unit)))
307
+ continue;
308
+ units.add(unit);
309
+ unit.prepare?.();
310
+ unit.prepare = null;
311
+ unit.edges ??= this.edgesOf(unit);
312
+ pending.push(...unit.with, ...unit.edges);
313
+ }
314
+ const byFile = new Map();
315
+ const places = [];
316
+ const fixtures = [];
317
+ let asks = [];
318
+ const snapshots = [];
319
+ for (const unit of units) {
320
+ snapshots.push(...unit.snapshots);
321
+ if (unit.place)
322
+ places.push(unit.place);
323
+ if (unit.fixtures)
324
+ fixtures.push(unit.fixtures);
325
+ if (unit.asks === null)
326
+ asks = null;
327
+ else if (asks !== null && unit.asks.length > 0)
328
+ asks.push(...unit.asks);
329
+ const hash = this.hashOf(unit);
330
+ if (hash === null)
331
+ continue;
332
+ const hashes = byFile.get(unit.module.relativeFilePath);
333
+ if (hashes)
334
+ hashes.push(hash);
335
+ else
336
+ byFile.set(unit.module.relativeFilePath, [hash]);
337
+ }
338
+ // Sorted by hash, so that moving code around in a file doesn't change it; each part keeps its own order.
339
+ for (const hashes of byFile.values())
340
+ hashes.sort();
341
+ return { units, byFile, places, fixtures, asks, snapshots };
342
+ }
343
+ /**
344
+ * Everything a test's roots reach on top of `base`, the code its file runs when loaded, which all its tests share,
345
+ * with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
346
+ * followed.
347
+ */
348
+ dependencies(roots, bases, needs, snapshots, report) {
349
+ const reached = [...bases, this.reach(roots, bases)];
350
+ // A fixture can ask for one an object it extends defines, or use another test object, so names are shared by
351
+ // every test object reached, and each name and object is read once.
352
+ const names = new Set();
353
+ const queue = [];
354
+ const objects = [];
355
+ const taken = new Set();
356
+ let fresh = [];
357
+ let all = false;
358
+ const need = (name) => {
359
+ if (names.has(name))
360
+ return;
361
+ names.add(name);
362
+ queue.push(name);
363
+ };
364
+ const takeAll = () => {
365
+ all = true;
366
+ for (const item of objects)
367
+ for (const name of item.byName.keys())
368
+ need(name);
369
+ };
370
+ const resolve = (item, name) => {
371
+ for (const fixture of item.byName.get(name) ?? []) {
372
+ if (taken.has(fixture.unit))
373
+ continue;
374
+ taken.add(fixture.unit);
375
+ fresh.push(fixture.unit);
376
+ if (fixture.uses === null) {
377
+ if (!all)
378
+ takeAll();
379
+ }
380
+ else {
381
+ for (const used of fixture.uses)
382
+ need(used);
383
+ }
384
+ }
385
+ };
386
+ const found = (reach) => {
387
+ if (reach.asks === null && !all)
388
+ takeAll();
389
+ for (const name of reach.asks ?? [])
390
+ need(name);
391
+ // Each walk skips what earlier ones reached, so an object turns up once.
392
+ for (const item of reach.fixtures) {
393
+ objects.push(item);
394
+ for (const name of all ? item.byName.keys() : item.auto)
395
+ need(name);
396
+ for (const name of names)
397
+ resolve(item, name);
398
+ }
399
+ };
400
+ for (const name of needs.names)
401
+ need(name);
402
+ for (const reach of reached)
403
+ found(reach);
404
+ if (needs.all)
405
+ takeAll();
406
+ while (queue.length > 0 || fresh.length > 0) {
407
+ for (let name = queue.pop(); name !== undefined; name = queue.pop()) {
408
+ for (const item of objects)
409
+ resolve(item, name);
410
+ }
411
+ if (fresh.length > 0) {
412
+ const reach = this.reach(fresh, reached);
413
+ fresh = [];
414
+ reached.push(reach);
415
+ found(reach);
416
+ }
417
+ }
418
+ // The snapshot files the matchers it reaches compare against, which have no code to reach in turn.
419
+ const uses = reached.flatMap((item) => item.snapshots);
420
+ if (uses.length > 0)
421
+ reached.push(this.reach(snapshots(uses), reached));
422
+ const places = reached.flatMap((item) => item.places);
423
+ const start = (place) => place.problem.node.start;
424
+ // Compared by code unit, as `files` is, so that the order doesn't depend on the locale.
425
+ const path = (a, b) => a.relativeFilePath < b.relativeFilePath ? -1 : a.relativeFilePath > b.relativeFilePath ? 1 : 0;
426
+ places.sort((a, b) => path(a, b) || start(a) - start(b));
427
+ for (const place of places) {
428
+ const key = `${place.relativeFilePath}\0${start(place)}\0${place.problem.message}`;
429
+ if (place.isQuiet || this.reported.has(key))
430
+ continue;
431
+ this.reported.add(key);
432
+ report(place);
433
+ }
434
+ const files = [...new Set(reached.flatMap((item) => [...item.byFile.keys()]))].sort();
435
+ const parts = files.map((file) => {
436
+ return [file, reached.flatMap((item) => item.byFile.get(file) ?? []).sort()];
437
+ });
438
+ return { hash: shortHash(JSON.stringify(parts)), files, isComplete: places.length === 0 };
439
+ }
440
+ newModule(relativeFilePath, parsed, text, values, readModule) {
441
+ return {
442
+ relativeFilePath,
443
+ parsed,
444
+ text,
445
+ values,
446
+ readModule,
447
+ load: [null, null],
448
+ whole: [null, null],
449
+ imports: new Map(),
450
+ reads: new Map(),
451
+ };
452
+ }
453
+ newUnit(module, roots, cut, types) {
454
+ return {
455
+ module,
456
+ roots,
457
+ cut,
458
+ types,
459
+ with: [],
460
+ edges: null,
461
+ place: null,
462
+ fixtures: null,
463
+ prepare: null,
464
+ asks: [],
465
+ snapshots: [],
466
+ hash: undefined,
467
+ };
468
+ }
469
+ hashOf(unit) {
470
+ if (unit.hash === undefined) {
471
+ if (unit.module.text !== null) {
472
+ unit.hash = shortHash(unit.module.text);
473
+ }
474
+ else {
475
+ const cut = [...unit.cut].map(([start, end]) => ({ start, end }));
476
+ unit.hash = hashNodes(unit.roots.map((root) => root.node), cut);
477
+ }
478
+ }
479
+ return unit.hash;
480
+ }
481
+ /** The module a file imports, read and parsed once per scan. */
482
+ moduleOf(source) {
483
+ let module = this.modules.get(source);
484
+ if (!module) {
485
+ if (CODE_FILE.test(source.relativeFilePath)) {
486
+ const parsed = parseModule(source);
487
+ module =
488
+ this.modules.get(parsed) ??
489
+ this.newModule(source.relativeFilePath, parsed, null, new Values(), source.readModule);
490
+ this.modules.set(parsed, module);
491
+ }
492
+ else {
493
+ module = this.newModule(source.relativeFilePath, null, source.code.replace(/\r\n?/g, '\n'), new Values(), null);
494
+ }
495
+ this.modules.set(source, module);
496
+ }
497
+ return module;
498
+ }
499
+ textUnit(module) {
500
+ const cached = module.whole[0];
501
+ if (cached)
502
+ return cached;
503
+ const unit = this.newUnit(module, [], new Map(), false);
504
+ unit.edges = new Set();
505
+ module.whole = [unit, unit];
506
+ module.load = [unit, unit];
507
+ return unit;
508
+ }
509
+ wholeUnit(module, types) {
510
+ const index = Number(types);
511
+ const cached = module.whole[index];
512
+ if (cached)
513
+ return cached;
514
+ const program = module.parsed.program;
515
+ const statements = program.body.filter((statement) => statement.type !== 'ImportDeclaration');
516
+ const unit = this.newUnit(module, statements.map((node) => ({ node, scopes: [program] })), new Map(), types);
517
+ unit.with.push(this.loadUnit(module, types));
518
+ module.whole[index] = unit;
519
+ return unit;
520
+ }
521
+ /** The unit of a declaration: a declarator, function, class, enum, namespace or `import x = require()`. */
522
+ declarationUnit(module, declaration, types, roots) {
523
+ const cache = this.units[Number(types)];
524
+ // A function's parameters alone are a unit of their own, kept apart from the whole function.
525
+ const key = roots ?? declaration;
526
+ let unit = cache.get(key);
527
+ if (!unit) {
528
+ const program = module.parsed.program;
529
+ const scopes = scopesAt(program, declaration);
530
+ const nodes = roots ?? [declaration];
531
+ const inner = roots ? [...scopes, declaration] : scopes;
532
+ unit = this.newUnit(module, nodes.map((node) => ({ node, scopes: inner })), new Map(), types);
533
+ // Code in a module runs only once the module is loaded, which runs its top-level code.
534
+ unit.with.push(this.loadUnit(module, types));
535
+ cache.set(key, unit);
536
+ }
537
+ return unit;
538
+ }
539
+ /** Whether a name is bound to a test object, once per declaration, as a file may call a helper thousands of times. */
540
+ isTestObject(module, name, declaration, scopes, types) {
541
+ let found = this.calledTests.get(declaration);
542
+ if (found === undefined) {
543
+ found = this.frameworkOf(module, name, scopes, types) !== null;
544
+ this.calledTests.set(declaration, found);
545
+ }
546
+ return found;
547
+ }
548
+ /**
549
+ * A file a test compares against, by its bytes: a snapshot or a screenshot, read once per scan however many tests
550
+ * compare against it; null when there is none yet.
551
+ */
552
+ snapshotUnit(relativeFilePath, read) {
553
+ let unit = this.snapshotFiles.get(relativeFilePath);
554
+ if (unit === undefined) {
555
+ const bytes = read();
556
+ if (bytes === null || bytes === 'unreadable')
557
+ unit = bytes;
558
+ else {
559
+ unit = this.newUnit(this.newModule(relativeFilePath, null, null, new Values(), null), [], new Map(), false);
560
+ unit.edges = new Set();
561
+ unit.hash = shortHash(bytes);
562
+ }
563
+ this.snapshotFiles.set(relativeFilePath, unit);
564
+ }
565
+ return unit;
566
+ }
567
+ /** A place in a scanned file that marks its tests without a warning, as for what one file's code can't show. */
568
+ quietUnit(module, message) {
569
+ return this.placeUnit(module, { start: 0, end: 0 }, message, true);
570
+ }
571
+ /** An import of a name a test uses: the module it names and how, as `import { round } from 'lodash'`. */
572
+ importUnit(module, statement, specifier) {
573
+ let unit = this.imports.get(specifier);
574
+ if (!unit) {
575
+ const roots = [statement.source, specifier].map((node) => ({ node, scopes: [] }));
576
+ unit = this.newUnit(module, roots, new Map(), false);
577
+ unit.edges = new Set();
578
+ this.imports.set(specifier, unit);
579
+ }
580
+ return unit;
581
+ }
582
+ /** A place in a module the scan can't follow, as a unit that marks the tests that reach it. */
583
+ placeUnit(module, at, message, isQuiet = false) {
584
+ const { source } = module.parsed;
585
+ const unit = this.newUnit(module, [], new Map(), false);
586
+ unit.edges = new Set();
587
+ const text = snippet(source.slice(at.start, at.end)) || null;
588
+ unit.place = {
589
+ relativeFilePath: module.relativeFilePath,
590
+ source,
591
+ problem: problem('dependency-not-followed', at, message, text),
592
+ isQuiet,
593
+ };
594
+ return unit;
595
+ }
596
+ /**
597
+ * A module the config runs each test with, taken whole: the config itself, a setup file, or a file of a setup
598
+ * project.
599
+ */
600
+ runUnit(source, isConfig) {
601
+ const module = this.moduleOf(source);
602
+ if (module.parsed === null)
603
+ return this.textUnit(module);
604
+ const { error } = module.parsed;
605
+ if (error) {
606
+ // A config that doesn't parse is reported when the scan reads it.
607
+ const message = "The file could not be parsed, so dependencyHash doesn't cover it.";
608
+ return this.placeUnit(module, error, message, isConfig);
609
+ }
610
+ return this.wholeUnit(module, false);
611
+ }
612
+ /** Something in a config the scan can't follow, as a unit that marks the tests whose config it is. */
613
+ gapUnit(gap) {
614
+ if ('module' in gap) {
615
+ const module = this.moduleOf(gap.module);
616
+ // The path a setup key names, where the config writes it.
617
+ const at = gap.text === null ? null : literalIn(module.parsed.program, gap.text);
618
+ return this.placeUnit(module, at ?? { start: 0, end: 0 }, gap.message, gap.isQuiet);
619
+ }
620
+ const module = this.newModule(gap.relativeFilePath, null, null, new Values(), null);
621
+ const unit = this.newUnit(module, [], new Map(), false);
622
+ unit.edges = new Set();
623
+ const text = snippet(gap.source.slice(gap.span.start, gap.span.end));
624
+ unit.place = {
625
+ relativeFilePath: gap.relativeFilePath,
626
+ source: gap.source,
627
+ problem: problem('dependency-not-followed', gap.span, gap.message, text),
628
+ isQuiet: gap.isQuiet,
629
+ };
630
+ return unit;
631
+ }
632
+ /**
633
+ * What a module's import of `name` from `specifier` stands for, `at` being the statement in `from` that imports it:
634
+ * the declaration it exports, `*` for all of it, or `''` for the code it runs when loaded. Installed packages give
635
+ * nothing; a local module that can't be read gives a place.
636
+ */
637
+ imported(from, specifier, name, types, at) {
638
+ const key = `${Number(types)}\0${specifier}\0${name}\0${at.start}`;
639
+ let units = from.imports.get(key);
640
+ if (!units) {
641
+ from.imports.set(key, IN_PROGRESS);
642
+ units = this.readImport(from, specifier, name, types, at);
643
+ // Within a cycle, the answer depends on where the search started, so it isn't kept.
644
+ if (units === IN_PROGRESS)
645
+ from.imports.delete(key);
646
+ else
647
+ from.imports.set(key, units);
648
+ }
649
+ return units;
650
+ }
651
+ /** The module a specifier names, from where `from` is; null when no module is read, as for one file's code. */
652
+ read(from, specifier) {
653
+ if (!from.readModule)
654
+ return null;
655
+ let read = from.reads.get(specifier);
656
+ if (!read) {
657
+ // A query, as in Vite's `./script.ts?raw`, says how to load the file, not which file.
658
+ read = from.readModule(specifier.replace(/\?.*$/s, ''));
659
+ from.reads.set(specifier, read);
660
+ }
661
+ return read;
662
+ }
663
+ readImport(from, specifier, name, types, at) {
664
+ const read = this.read(from, specifier);
665
+ if (read === null) {
666
+ return isLocalSpecifier(specifier) ? [this.placeUnit(from, at, `'${specifier}' is not read.`, true)] : [];
667
+ }
668
+ if ('reason' in read) {
669
+ if (PACKAGE_REASONS.has(read.reason))
670
+ return [];
671
+ return [this.placeUnit(from, at, `'${specifier}' ${read.reason}, so dependencyHash doesn't cover it.`)];
672
+ }
673
+ const module = this.moduleOf(read);
674
+ if (module.parsed === null)
675
+ return [this.textUnit(module)];
676
+ if (DECLARATION_FILE.test(module.relativeFilePath) && !types) {
677
+ // An import only types use is dropped when the code is compiled; a value the test uses runs code from elsewhere.
678
+ if (name === '')
679
+ return [];
680
+ return [
681
+ this.placeUnit(from, at, `'${specifier}' leads to a declaration file, so dependencyHash doesn't cover the code that runs.`),
682
+ ];
683
+ }
684
+ if (module.parsed.error) {
685
+ return [this.placeUnit(from, at, `'${specifier}' could not be parsed, so dependencyHash doesn't cover it.`)];
686
+ }
687
+ return name === '' ? [this.loadUnit(module, types)] : this.exported(module, name, types, new Set());
688
+ }
689
+ /**
690
+ * The units of what a module exports under a name: its own export first, then one an `export *` passes on, as the
691
+ * module system picks them. `*` is the whole module. A name the scan can't find, as with CommonJS exports, also
692
+ * takes the whole module, so that nothing it may run is left out.
693
+ */
694
+ exported(module, name, types, searched) {
695
+ if (name === '*' || searched.has(module))
696
+ return [this.wholeUnit(module, types)];
697
+ searched.add(module);
698
+ const program = module.parsed.program;
699
+ const local = (localName) => {
700
+ const declaration = module.values.declarationOf(localName, [program]) ?? otherDeclaration(localName, [program], types);
701
+ // A global the module re-exports, such as `structuredClone`, has no code in the project.
702
+ if (declaration === null)
703
+ return [];
704
+ return this.toDeclaration(module, declaration, localName, null, types);
705
+ };
706
+ for (const statement of program.body) {
707
+ if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
708
+ const declaration = statement.declaration;
709
+ return declaration.type === 'Identifier'
710
+ ? local(declaration.name)
711
+ : [this.declarationUnit(module, declaration, types)];
712
+ }
713
+ if (statement.type === 'ExportAllDeclaration' && statement.exported && exportName(statement.exported) === name) {
714
+ return this.imported(module, statement.source.value, '*', types, statement);
715
+ }
716
+ if (statement.type !== 'ExportNamedDeclaration')
717
+ continue;
718
+ if (statement.exportKind === 'type' && !types)
719
+ continue;
720
+ const declaration = statement.declaration;
721
+ if (declaration) {
722
+ if (declaration.type === 'VariableDeclaration') {
723
+ const declarator = declaration.declarations.find((item) => bindingNames(item.id).includes(name));
724
+ if (declarator)
725
+ return this.toDeclaration(module, declarator, name, null, types);
726
+ }
727
+ else if ('id' in declaration && declaration.id?.type === 'Identifier' && declaration.id.name === name) {
728
+ // `export import Fees = require('./fees')` stands for the other module.
729
+ return this.toDeclaration(module, declaration, name, null, types);
730
+ }
731
+ continue;
732
+ }
733
+ const specifier = statement.specifiers.find((item) => exportName(item.exported) === name);
734
+ if (!specifier)
735
+ continue;
736
+ const localName = exportName(specifier.local);
737
+ return statement.source
738
+ ? this.imported(module, statement.source.value, localName, types, statement)
739
+ : local(localName);
740
+ }
741
+ // `export *` passes on every name but `default`, and only when the module doesn't export the name itself.
742
+ if (name !== 'default') {
743
+ for (const statement of program.body) {
744
+ if (statement.type !== 'ExportAllDeclaration' || statement.exported)
745
+ continue;
746
+ const found = this.reexported(module, statement.source.value, name, types, searched);
747
+ if (found)
748
+ return found;
749
+ }
750
+ }
751
+ return [this.wholeUnit(module, types)];
752
+ }
753
+ /** What `export * from source` gives for a name; null when the module behind it doesn't export it. */
754
+ reexported(from, source, name, types, searched) {
755
+ const read = this.read(from, source);
756
+ if (!read || 'reason' in read)
757
+ return null;
758
+ const module = this.moduleOf(read);
759
+ if (module.parsed === null || module.parsed.error)
760
+ return null;
761
+ const found = this.exported(module, name, types, searched);
762
+ // A module that doesn't export the name falls back to the whole of it, which doesn't count as found here.
763
+ const isWhole = found.length === 1 && found[0] === module.whole[Number(types)];
764
+ return isWhole || found === IN_PROGRESS ? null : found;
765
+ }
766
+ /**
767
+ * The units a name stands for where it is declared: the declaration, the export of the module it's imported from, or
768
+ * for a parameter only the parameters, as the call that passes their values is followed on its own.
769
+ */
770
+ toDeclaration(module, declaration, name, member, types) {
771
+ switch (declaration.type) {
772
+ case 'ImportSpecifier':
773
+ case 'ImportDefaultSpecifier':
774
+ case 'ImportNamespaceSpecifier': {
775
+ const statement = this.importOf(module, declaration);
776
+ const imported = declaration.type === 'ImportSpecifier'
777
+ ? exportName(declaration.imported)
778
+ : declaration.type === 'ImportDefaultSpecifier'
779
+ ? 'default'
780
+ : (member ?? '*');
781
+ // Which module a name comes from is code too, also a package that isn't followed.
782
+ return [
783
+ ...this.imported(module, statement.source.value, imported, types, statement),
784
+ this.importUnit(module, statement, declaration),
785
+ ];
786
+ }
787
+ case 'TSImportEqualsDeclaration': {
788
+ const reference = declaration.moduleReference;
789
+ if (reference.type !== 'TSExternalModuleReference')
790
+ return [this.declarationUnit(module, declaration, types)];
791
+ return this.imported(module, reference.expression.value, member ?? '*', types, declaration);
792
+ }
793
+ case 'FunctionDeclaration':
794
+ case 'FunctionExpression':
795
+ case 'ArrowFunctionExpression':
796
+ if (declaration.params.some((parameter) => bindingNames(parameter).includes(name))) {
797
+ return [this.declarationUnit(module, declaration, types, declaration.params)];
798
+ }
799
+ break;
800
+ case 'VariableDeclarator': {
801
+ const testObject = this.testObjectUnit(module, declaration, types);
802
+ if (testObject)
803
+ return [testObject];
804
+ break;
805
+ }
806
+ }
807
+ return [this.declarationUnit(module, declaration, types)];
808
+ }
809
+ /**
810
+ * A test object built with `extend()` on the framework's own: its code without the fixtures its `extend()` calls
811
+ * define, which each test takes as it needs them. Null for any other value.
812
+ */
813
+ testObjectUnit(module, declarator, types) {
814
+ const cache = this.testObjects[Number(types)];
815
+ if (!cache.has(declarator)) {
816
+ // A test object built on itself, through imports, isn't one.
817
+ cache.set(declarator, null);
818
+ cache.set(declarator, this.readTestObject(module, declarator, types));
819
+ }
820
+ return cache.get(declarator) ?? null;
821
+ }
822
+ readTestObject(module, declarator, types) {
823
+ const scopes = scopesAt(module.parsed.program, declarator);
824
+ const calls = [];
825
+ let node = declarator.init && unwrap(declarator.init);
826
+ while (node?.type === 'CallExpression' && node.callee.type === 'MemberExpression' && !node.callee.computed) {
827
+ if (node.callee.property.name !== 'extend')
828
+ break;
829
+ calls.push(node);
830
+ node = unwrap(node.callee.object);
831
+ }
832
+ const framework = node && calls.length > 0 ? this.frameworkOf(module, node, scopes, types) : null;
833
+ if (!framework)
834
+ return null;
835
+ const byName = new Map();
836
+ const auto = [];
837
+ const cut = [];
838
+ const define = (name, unit, value, options, where = { module, node: declarator, scopes }) => {
839
+ const uses = this.fixtureUses(where.module, value, where.scopes, types);
840
+ byName.set(name, [...(byName.get(name) ?? []), { unit, uses }]);
841
+ const read = options && this.valueOf(where.module, options, where.scopes, types).node;
842
+ // Playwright's own fixtures and options, such as `page` or `storageState`, shape what every test runs.
843
+ const isBuiltIn = framework === 'playwright' && BUILT_INS.has(name);
844
+ if (isBuiltIn || (read?.type === 'ObjectExpression' && read.properties.some(isAutoOption))) {
845
+ auto.push(name);
846
+ }
847
+ };
848
+ for (const call of calls) {
849
+ const first = call.arguments.at(0);
850
+ if (first === undefined)
851
+ continue;
852
+ if (first.type === 'Literal' && typeof first.value === 'string') {
853
+ // Vitest's `test.extend('name', options?, fn)`.
854
+ cut.push(...call.arguments);
855
+ const unit = this.declarationUnit(module, call, types, call.arguments);
856
+ define(first.value, unit, call.arguments.at(-1), call.arguments.length > 2 ? call.arguments[1] : undefined);
857
+ }
858
+ else if (first.type === 'ObjectExpression' && first.properties.every(isNamedProperty)) {
859
+ cut.push(first);
860
+ for (const property of first.properties) {
861
+ // `[fn, { auto: true }]`, also when a constant holds the pair or the options.
862
+ const value = this.valueOf(module, property.value, scopes, types);
863
+ const [fn, options] = value.node.type === 'ArrayExpression' ? value.node.elements : [value.node];
864
+ const unit = this.declarationUnit(module, property, types);
865
+ define(propertyKey(property), unit, fn ?? undefined, options ?? undefined, value);
866
+ }
867
+ }
868
+ }
869
+ if (cut.length === 0)
870
+ return null;
871
+ const unit = this.newUnit(module, [{ node: declarator, scopes }], new Map(cut.map((span) => [span.start, span.end])), types);
872
+ unit.with.push(this.loadUnit(module, types));
873
+ unit.fixtures = { byName, auto, framework };
874
+ return unit;
875
+ }
876
+ /**
877
+ * The framework of a test object: its own `test`, `mergeTests()` of test objects, or one built on them with
878
+ * `extend()`, followed through imports; null for an expression that isn't one.
879
+ */
880
+ frameworkOf(module, expression, scopes, types, seen = new Set()) {
881
+ const node = unwrap(expression);
882
+ if (node.type === 'CallExpression') {
883
+ const { callee } = node;
884
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.name === 'extend') {
885
+ return this.frameworkOf(module, callee.object, scopes, types, seen);
886
+ }
887
+ // `mergeTests(a, b)`.
888
+ return this.frameworkOf(module, callee, scopes, types, seen);
889
+ }
890
+ if (node.type !== 'Identifier')
891
+ return null;
892
+ const declaration = module.values.declarationOf(node.name, scopes);
893
+ // Values built from each other, as `const poll = () => retry()` and `const retry = () => poll()`, end here.
894
+ if (declaration === null || seen.has(declaration))
895
+ return null;
896
+ seen.add(declaration);
897
+ if (declaration.type === 'ImportSpecifier') {
898
+ const source = this.importOf(module, declaration).source.value;
899
+ // `expect` has an extend() too, for matchers.
900
+ if (FRAMEWORK.test(source)) {
901
+ return TEST_EXPORTS.has(exportName(declaration.imported))
902
+ ? source === 'vitest'
903
+ ? 'vitest'
904
+ : 'playwright'
905
+ : null;
906
+ }
907
+ }
908
+ for (const unit of this.toDeclaration(module, declaration, node.name, null, types)) {
909
+ if (unit.fixtures)
910
+ return unit.fixtures.framework;
911
+ const root = unit.roots.at(0)?.node;
912
+ if (root?.type !== 'VariableDeclarator' || root.init === null)
913
+ continue;
914
+ const at = root === declaration ? scopes : scopesAt(unit.module.parsed.program, root);
915
+ const framework = this.frameworkOf(unit.module, root.init, at, types, seen);
916
+ if (framework)
917
+ return framework;
918
+ }
919
+ return null;
920
+ }
921
+ /**
922
+ * Takes what the values given to `test.use()`, `override()` or `scoped()` ask for: functions and `[fn, options]`
923
+ * pairs, as `extend()` takes them. Playwright's options and a spread of its `devices` are values, not fixtures.
924
+ */
925
+ useValues(values, take) {
926
+ for (const property of values.properties) {
927
+ if (property.type === 'SpreadElement') {
928
+ const argument = unwrap(property.argument);
929
+ const isDevice = argument.type === 'MemberExpression' &&
930
+ argument.object.type === 'Identifier' &&
931
+ argument.object.name === 'devices';
932
+ if (!isDevice)
933
+ take(property);
934
+ continue;
935
+ }
936
+ const value = unwrap(property.value);
937
+ const fn = value.type === 'ArrayExpression' ? value.elements.at(0) : value;
938
+ const isOption = BUILT_INS.has(propertyKey(property));
939
+ if (fn && (isFunctionNode(fn) || !isOption))
940
+ take(fn);
941
+ }
942
+ }
943
+ /**
944
+ * Whether a call is on a test object, so that its functions run with fixtures: the framework's own, a global one,
945
+ * one built with `extend()`, or a parameter, as a helper that takes the test object has.
946
+ */
947
+ isTestReference(module, callee, scopes, types) {
948
+ let root = callee;
949
+ while (root.type === 'MemberExpression' || root.type === 'CallExpression') {
950
+ root = root.type === 'MemberExpression' ? root.object : root.callee;
951
+ }
952
+ if (root.type !== 'Identifier')
953
+ return false;
954
+ const declaration = module.values.declarationOf(root.name, scopes);
955
+ if (declaration === null)
956
+ return GLOBAL_TESTS.has(root.name);
957
+ if (declaration.type === 'ImportSpecifier') {
958
+ if (FRAMEWORK.test(this.importOf(module, declaration).source.value))
959
+ return true;
960
+ }
961
+ // A parameter's binding is the function it belongs to.
962
+ if (declaration.type === 'FunctionDeclaration' || isFunctionNode(declaration)) {
963
+ return declaration.params.some((parameter) => bindingNames(parameter).includes(root.name));
964
+ }
965
+ return this.frameworkOf(module, root, scopes, types) !== null;
966
+ }
967
+ /**
968
+ * The fixtures a fixture's or hook's function asks for. A value such as a constant asks for none; null when the
969
+ * function takes them whole, or the value can't be told, as a factory's result or a fixture from a package.
970
+ */
971
+ fixtureUses(module, value, scopes, types) {
972
+ if (!value)
973
+ return [];
974
+ const { node } = this.valueOf(module, value, scopes, types);
975
+ if (node.type === 'FunctionDeclaration' || isFunctionNode(node)) {
976
+ return takesWhole(node, 0) ? null : destructuredNames(node, 0);
977
+ }
978
+ return isPlainValue(node) ? [] : null;
979
+ }
980
+ /**
981
+ * What an expression holds, followed through constants and imports to the function or value they are bound to; the
982
+ * expression itself when it is something else, with the module and scopes to read it in.
983
+ */
984
+ valueOf(module, value, scopes, types, depth = 0) {
985
+ const node = unwrap(value);
986
+ if (node.type !== 'Identifier' || depth > MAX_VALUE_DEPTH)
987
+ return { module, node, scopes };
988
+ // Only a `const` holds one value; a `let` may be given another later.
989
+ const constant = module.values.constantOf(node.name, scopes);
990
+ if (constant)
991
+ return this.valueOf(module, constant.init, constant.scopes, types, depth + 1);
992
+ const declaration = module.values.declarationOf(node.name, scopes);
993
+ if (declaration === null || declaration.type === 'VariableDeclarator')
994
+ return { module, node, scopes };
995
+ for (const unit of this.toDeclaration(module, declaration, node.name, null, types)) {
996
+ const root = unit.roots.at(0)?.node;
997
+ if (root?.type === 'FunctionDeclaration')
998
+ return { module: unit.module, node: root, scopes };
999
+ if (root?.type === 'VariableDeclarator' && root.init && isConst(unit.module, root)) {
1000
+ const at = scopesAt(unit.module.parsed.program, root);
1001
+ return this.valueOf(unit.module, root.init, at, types, depth + 1);
1002
+ }
1003
+ }
1004
+ return { module, node, scopes };
1005
+ }
1006
+ importOf(module, specifier) {
1007
+ const program = module.parsed.program;
1008
+ return program.body.find((statement) => statement.type === 'ImportDeclaration' && contains(statement, specifier));
1009
+ }
1010
+ /** The units a unit uses directly: the declarations its names refer to, and the modules it loads. */
1011
+ edgesOf(unit) {
1012
+ const edges = new Set();
1013
+ const { module, types } = unit;
1014
+ const program = module.parsed.program;
1015
+ const isOwn = (declaration) => unit.roots.some((root) => contains(root.node, declaration));
1016
+ const add = (units) => {
1017
+ for (const target of units)
1018
+ edges.add(target);
1019
+ };
1020
+ const follow = (name, scopes, member) => {
1021
+ const declaration = module.values.declarationOf(name, scopes) ?? otherDeclaration(name, scopes, types);
1022
+ // An import in the unit's own code still names code elsewhere.
1023
+ if (declaration === null || (isOwn(declaration) && !IMPORT_BINDINGS.has(declaration.type)))
1024
+ return;
1025
+ add(this.toDeclaration(module, declaration, name, member, types));
1026
+ };
1027
+ /** A module loaded by the path in `argument`, or a place when the path is worked out at run time. */
1028
+ const loadsByPath = (argument, at) => {
1029
+ // `vi.mock(import('./broker'))`: the walk reads the import() itself.
1030
+ if (argument?.type === 'ImportExpression')
1031
+ return;
1032
+ if (argument?.type === 'Literal' && typeof argument.value === 'string') {
1033
+ add(this.imported(module, argument.value, '*', types, at));
1034
+ }
1035
+ else {
1036
+ edges.add(this.placeUnit(module, at, COMPUTED));
1037
+ }
1038
+ };
1039
+ /** The fixtures a call's functions ask for, when it registers a hook, a condition, a test or `use()` values. */
1040
+ const asks = (call, scopes) => {
1041
+ const { callee } = call;
1042
+ const name = callee.type === 'Identifier'
1043
+ ? callee.name
1044
+ : callee.type === 'MemberExpression' && !callee.computed
1045
+ ? callee.property.name
1046
+ : null;
1047
+ if (name === null)
1048
+ return;
1049
+ // A test object imported under another name, as Playwright's `import { test as setup }` for setup projects;
1050
+ // `mergeTests()` combines test objects, and isn't one.
1051
+ const declaration = callee.type === 'Identifier' ? module.values.declarationOf(callee.name, scopes) : null;
1052
+ const isMerge = declaration?.type === 'ImportSpecifier' && exportName(declaration.imported) === 'mergeTests';
1053
+ const isTest = declaration !== null && !isMerge && this.isTestObject(module, callee, declaration, scopes, types);
1054
+ if (!(isTest || FIXTURE_CALLS.has(name) || CONDITION_CALLS.has(name) || USE_CALLS.has(name)))
1055
+ return;
1056
+ // A fixture's own `use(value)` callback is called by name; `test.use()` is a member.
1057
+ if (USE_CALLS.has(name) && callee.type !== 'MemberExpression')
1058
+ return;
1059
+ // `/^[A-Z]+$/.test(symbol)`, `assert.fail()` or `app.use()` don't run with fixtures.
1060
+ if (!this.isTestReference(module, callee, scopes, types))
1061
+ return;
1062
+ const take = (value) => {
1063
+ const uses = this.fixtureUses(module, value, scopes, types);
1064
+ unit.asks = unit.asks && uses && [...unit.asks, ...uses];
1065
+ };
1066
+ if (USE_CALLS.has(name)) {
1067
+ // `test.use(MARGIN_ACCOUNT)` is followed to the object, as written in place.
1068
+ for (const values of call.arguments.slice(0, 1)) {
1069
+ const { node } = this.valueOf(module, values, scopes, types);
1070
+ if (node.type === 'ObjectExpression')
1071
+ this.useValues(node, take);
1072
+ else
1073
+ take(values);
1074
+ }
1075
+ return;
1076
+ }
1077
+ for (const argument of call.arguments) {
1078
+ const { node } = this.valueOf(module, argument, scopes, types);
1079
+ if (CONDITION_CALLS.has(name)) {
1080
+ // A condition's value, as in `test.skip(isCI)`, isn't a function of the fixtures.
1081
+ if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
1082
+ take(argument);
1083
+ }
1084
+ else if (!isPlainValue(node)) {
1085
+ // A title or options, or else a function, also one a factory, a member or a condition gives.
1086
+ take(argument);
1087
+ }
1088
+ }
1089
+ };
1090
+ /** A snapshot matcher's call: the file it compares against, or nothing for one written inline. */
1091
+ const snapshot = (matcher, call, scopes) => {
1092
+ const first = call.arguments.at(0);
1093
+ const value = first === undefined ? null : this.valueOf(module, first, scopes, types).node;
1094
+ // `toMatchAriaSnapshot('- heading "Portfolio"')` holds the snapshot in the code, also from a constant.
1095
+ const isInline = value?.type === 'TemplateLiteral' || (value?.type === 'Literal' && typeof value.value === 'string');
1096
+ if (matcher === 'toMatchAriaSnapshot' && isInline)
1097
+ return;
1098
+ const named = value === null ? null : snapshotName(value);
1099
+ if (matcher === 'toMatchFileSnapshot' && (named === null || isAbsolute(named.name))) {
1100
+ edges.add(this.placeUnit(module, call, unplacedFile(named === null)));
1101
+ return;
1102
+ }
1103
+ unit.snapshots.push({ matcher, name: named?.name ?? null, isParts: named?.isParts ?? false });
1104
+ };
1105
+ const walk = (node, scopes, key, parent) => {
1106
+ if (unit.cut.get(node.start) === node.end)
1107
+ return;
1108
+ switch (node.type) {
1109
+ case 'Identifier':
1110
+ if (isReference(key, parent))
1111
+ follow(node.name, scopes, null);
1112
+ break;
1113
+ case 'JSXIdentifier':
1114
+ // `<div>` is an element; `<OrderTicket>` a component.
1115
+ if (key === 'name' && parent?.type !== 'JSXAttribute' && /^[A-Z_$]/.test(node.name)) {
1116
+ follow(node.name, scopes, null);
1117
+ }
1118
+ return;
1119
+ case 'MemberExpression':
1120
+ case 'JSXMemberExpression': {
1121
+ // `pages.OrderTicket` on a namespace import needs only that export.
1122
+ const { object, property } = node;
1123
+ const named = node.type === 'JSXMemberExpression' || !node.computed;
1124
+ if (named && (object.type === 'Identifier' || object.type === 'JSXIdentifier')) {
1125
+ // A property that isn't computed is a name.
1126
+ follow(object.name, scopes, property.name);
1127
+ return;
1128
+ }
1129
+ break;
1130
+ }
1131
+ case 'ImportExpression':
1132
+ loadsByPath(node.source, node);
1133
+ break;
1134
+ case 'CallExpression': {
1135
+ const { callee } = node;
1136
+ asks(node, scopes);
1137
+ if (callee.type === 'Identifier' && callee.name === 'require')
1138
+ loadsByPath(node.arguments.at(0), node);
1139
+ if (callee.type === 'MemberExpression' && !callee.computed) {
1140
+ const { object, property } = callee;
1141
+ if (SNAPSHOT_MATCHERS.has(property.name))
1142
+ snapshot(property.name, node, scopes);
1143
+ // `Snapshots.toMatchSnapshot.call(this, received)` in a custom matcher, or one taken out of `Snapshots`.
1144
+ const target = object.type === 'MemberExpression' && !object.computed
1145
+ ? object.property.name
1146
+ : object.type === 'Identifier'
1147
+ ? object.name
1148
+ : '';
1149
+ if (CALLS.has(property.name) && target === 'toMatchFileSnapshot') {
1150
+ edges.add(this.placeUnit(module, node, unplacedFile(true)));
1151
+ }
1152
+ else if (CALLS.has(property.name) && SNAPSHOT_MATCHERS.has(target)) {
1153
+ unit.snapshots.push({ matcher: target, name: null, isParts: false });
1154
+ }
1155
+ if (object.type === 'Identifier' && object.name === 'vi' && VI_MODULE_CALLS.has(property.name)) {
1156
+ loadsByPath(node.arguments.at(0), node);
1157
+ }
1158
+ if (object.type === 'MetaProperty' && property.name.startsWith('glob')) {
1159
+ edges.add(this.placeUnit(module, node, "The modules this loads match a pattern, so dependencyHash doesn't cover them."));
1160
+ }
1161
+ }
1162
+ break;
1163
+ }
1164
+ case 'ImportDeclaration':
1165
+ if (node.importKind !== 'type' || types)
1166
+ add(this.imported(module, node.source.value, '', types, node));
1167
+ return;
1168
+ case 'TSImportEqualsDeclaration':
1169
+ if (node.moduleReference.type !== 'TSExternalModuleReference')
1170
+ break;
1171
+ if (node.importKind !== 'type' || types) {
1172
+ add(this.imported(module, node.moduleReference.expression.value, '', types, node));
1173
+ }
1174
+ return;
1175
+ // Re-exports, in a module taken whole: each name comes from the other module.
1176
+ case 'ExportNamedDeclaration':
1177
+ if (!node.source)
1178
+ break;
1179
+ if (node.exportKind !== 'type' || types) {
1180
+ for (const specifier of node.specifiers) {
1181
+ add(this.imported(module, node.source.value, exportName(specifier.local), types, node));
1182
+ }
1183
+ }
1184
+ return;
1185
+ case 'ExportAllDeclaration':
1186
+ if (node.exportKind !== 'type' || types)
1187
+ add(this.imported(module, node.source.value, '*', types, node));
1188
+ return;
1189
+ }
1190
+ const isScope = isScopeNode(node);
1191
+ if (isScope)
1192
+ scopes.push(node);
1193
+ const fields = node;
1194
+ for (const field of visitorKeys[node.type]) {
1195
+ if (!types && TYPE_KEYS.has(field))
1196
+ continue;
1197
+ const value = fields[field];
1198
+ if (Array.isArray(value)) {
1199
+ for (const item of value)
1200
+ if (item)
1201
+ walk(item, scopes, field, node);
1202
+ }
1203
+ else if (value) {
1204
+ walk(value, scopes, field, node);
1205
+ }
1206
+ }
1207
+ if (isScope)
1208
+ scopes.pop();
1209
+ };
1210
+ for (const root of unit.roots)
1211
+ walk(root.node, [...root.scopes], null, null);
1212
+ // A module's imports run when it is loaded, whatever of them it uses.
1213
+ if (unit === module.load[Number(types)]) {
1214
+ for (const statement of program.body) {
1215
+ if (statement.type === 'ImportDeclaration' || statement.type === 'TSImportEqualsDeclaration') {
1216
+ walk(statement, [program], null, null);
1217
+ }
1218
+ if ((statement.type === 'ExportAllDeclaration' || statement.type === 'ExportNamedDeclaration') &&
1219
+ statement.source &&
1220
+ (statement.exportKind !== 'type' || types)) {
1221
+ add(this.imported(module, statement.source.value, '', types, statement));
1222
+ }
1223
+ }
1224
+ }
1225
+ return edges;
1226
+ }
1227
+ }
1228
+ /** Whether an identifier refers to a binding, rather than naming a property, a label or a member. */
1229
+ function isReference(key, parent) {
1230
+ switch (parent?.type) {
1231
+ case 'MemberExpression':
1232
+ return key === 'object' || parent.computed;
1233
+ case 'Property':
1234
+ case 'MethodDefinition':
1235
+ case 'PropertyDefinition':
1236
+ case 'AccessorProperty':
1237
+ case 'TSPropertySignature':
1238
+ case 'TSMethodSignature':
1239
+ return key !== 'key' || parent.computed;
1240
+ case 'LabeledStatement':
1241
+ case 'BreakStatement':
1242
+ case 'ContinueStatement':
1243
+ return false;
1244
+ case 'TSEnumMember':
1245
+ case 'TSModuleDeclaration':
1246
+ case 'TSTypeParameter':
1247
+ case 'TSImportEqualsDeclaration':
1248
+ return key !== 'id' && key !== 'name';
1249
+ case 'TSQualifiedName':
1250
+ return key === 'left';
1251
+ case 'ExportSpecifier':
1252
+ return key === 'local';
1253
+ case 'MetaProperty':
1254
+ return false;
1255
+ default:
1256
+ return true;
1257
+ }
1258
+ }
1259
+ /**
1260
+ * A namespace or `import x = require()` a name refers to, or for a type test also an interface or type alias, looked
1261
+ * up in the scopes around it. Imports, classes and enums are bindings `Values` already finds.
1262
+ */
1263
+ function otherDeclaration(name, scopes, types) {
1264
+ for (let index = scopes.length - 1; index >= 0; index--) {
1265
+ const scope = scopes[index];
1266
+ const body = scope.type === 'Program' || scope.type === 'BlockStatement' || scope.type === 'TSModuleBlock' ? scope.body : [];
1267
+ for (const statement of body) {
1268
+ const node = declared(statement);
1269
+ const isNamed = 'id' in node && node.id?.type === 'Identifier' && node.id.name === name;
1270
+ const isValue = node.type === 'TSModuleDeclaration' || node.type === 'TSImportEqualsDeclaration';
1271
+ if (isNamed && (isValue || (types && TYPES.has(node.type))))
1272
+ return node;
1273
+ }
1274
+ }
1275
+ return null;
1276
+ }
1277
+ /** The first string literal with a value in a tree, in source order; null when there is none. */
1278
+ function literalIn(node, value) {
1279
+ if (node.type === 'Literal' && node.value === value)
1280
+ return node;
1281
+ for (const child of childNodes(node)) {
1282
+ const found = literalIn(child, value);
1283
+ if (found)
1284
+ return found;
1285
+ }
1286
+ return null;
1287
+ }
1288
+ /**
1289
+ * The snapshot files the matchers of a test compare against, from the project root, at the runners' default places:
1290
+ * Vitest's `__snapshots__/<file>.snap`, paths given to `toMatchFileSnapshot()`, and browser screenshots in
1291
+ * `__screenshots__/<file>/`, beside the test file; Playwright's `<file>-snapshots/` folder. Of a folder, the files that
1292
+ * start with a name given, or all of them when a matcher gives none; `outside` when a path leaves the project.
1293
+ */
1294
+ function snapshotPaths(testFile, uses, framework) {
1295
+ const folder = path.posix.dirname(testFile);
1296
+ const name = path.posix.basename(testFile);
1297
+ const found = new Set();
1298
+ const folders = new Map();
1299
+ /** A name in a folder; path parts, as Playwright takes, may lead to another folder. */
1300
+ const named = (target, stem) => {
1301
+ const file = stem === null ? null : path.posix.join(target, stem);
1302
+ const where = file === null ? target : path.posix.dirname(file);
1303
+ folders.set(where, [...(folders.get(where) ?? []), file === null ? null : path.posix.basename(file)]);
1304
+ };
1305
+ for (const use of uses) {
1306
+ if (framework === 'playwright')
1307
+ named(path.posix.join(folder, `${name}-snapshots`), playwrightStem(use));
1308
+ else if (use.matcher === 'toMatchScreenshot') {
1309
+ named(path.posix.join(folder, '__screenshots__', name), use.name === null ? null : vitestStem(use.name));
1310
+ }
1311
+ else if (use.matcher === 'toMatchFileSnapshot')
1312
+ found.add(path.posix.join(folder, use.name));
1313
+ else
1314
+ found.add(path.posix.join(folder, '__snapshots__', `${name}.snap`));
1315
+ }
1316
+ const isInside = (file) => !file.startsWith('../');
1317
+ return {
1318
+ files: [...found].filter(isInside),
1319
+ folders: new Map([...folders].filter(([where]) => isInside(where))),
1320
+ isOutside: ![...found, ...folders.keys()].every(isInside),
1321
+ };
1322
+ }
1323
+ /**
1324
+ * The start of the files Playwright writes for a name: without its extension, as the project and platform come
1325
+ * before it, and a string name sanitized as Playwright 1.64 does, where path parts are kept as written.
1326
+ */
1327
+ function playwrightStem(use) {
1328
+ const stem = use.name?.replace(/(\.aria\.yml|\.[^./]*)$/, '') ?? null;
1329
+ return stem !== null && !use.isParts ? stem.replace(UNSAFE_NAME, '-') : stem;
1330
+ }
1331
+ /** The start of the files Vitest 5 writes for a screenshot name, sanitized as its `sanitizeArg` does. */
1332
+ function vitestStem(name) {
1333
+ return name
1334
+ .replace(/\.png$/, '')
1335
+ .replace(/^\.\//, '')
1336
+ .replaceAll('..', '')
1337
+ .replace(/\s+/g, '-')
1338
+ .replace(/[^\w-]/g, '')
1339
+ .replace(/-{2,}/g, '-');
1340
+ }
1341
+ /** Whether a path is absolute, on any platform, as Vitest takes it as it is. */
1342
+ function isAbsolute(file) {
1343
+ return path.posix.isAbsolute(file) || path.win32.isAbsolute(file);
1344
+ }
1345
+ /** Why a file snapshot isn't followed: a path worked out at run time, or an absolute one. */
1346
+ function unplacedFile(isComputed) {
1347
+ const why = isComputed ? 'is worked out at run time' : "is an absolute path, which the scan doesn't place";
1348
+ return `The file this compares against ${why}, so dependencyHash doesn't cover it.`;
1349
+ }
1350
+ /** Whether a file in a snapshots folder is one a name gives, as Playwright and Vitest add a suffix to the name. */
1351
+ function hasStem(file, stem) {
1352
+ return stem === null || file === stem || file.startsWith(`${stem}-`) || file.startsWith(`${stem}.`);
1353
+ }
1354
+ /** The statements of a describe's callback, or its expression when it has no block body. */
1355
+ function bodyNodes(fn) {
1356
+ const body = fn.body;
1357
+ return body.type === 'BlockStatement' ? body.body : [body];
1358
+ }
1359
+ /**
1360
+ * What each test of a scanned file runs: its own call, the helper call that registers it, every describe around it
1361
+ * with its hooks and options, the file's own top-level code, and what all of these reach. `report` gets each place the
1362
+ * scan can't follow, once per scan.
1363
+ */
1364
+ export function fileDependencies(graph, main, extraction, types, report, runsWith = { config: null, modules: [], gaps: [], moved: null }, snapshotFiles = null) {
1365
+ const moduleOf = (frame) => graph.module(frame.parsed, frame.relativeFilePath, frame.values, frame.readModule);
1366
+ const mainModule = moduleOf(main);
1367
+ // The file's tests and describes, and the helper calls that declare them, are each test's own, not code every test
1368
+ // in the file or a describe runs.
1369
+ const items = [...extraction.tests, ...extraction.suites];
1370
+ const registered = items.flatMap((item) => (item.registeredBy ? [item.registeredBy] : []));
1371
+ const load = graph.loadUnit(mainModule, types, [...items.flatMap((item) => (item.frame === main ? [item.call] : [])), ...registered], true);
1372
+ const registering = (call) => (call ? [graph.unit(mainModule, [call], [], types)] : []);
1373
+ const suiteUnits = new Map();
1374
+ const suiteUnit = (suite) => {
1375
+ let units = suiteUnits.get(suite);
1376
+ if (!units) {
1377
+ // A describe without a callback has no tests in it.
1378
+ const fn = suite.declaration.fn;
1379
+ units = [
1380
+ graph.unit(moduleOf(suite.frame), [suite.call, ...bodyNodes(fn).filter(isLoadCode)],
1381
+ // Offsets in another file could match these by chance.
1382
+ [fn, ...suite.cuts, ...(suite.frame === main ? registered : [])], types),
1383
+ ...registering(suite.registeredBy),
1384
+ ];
1385
+ suiteUnits.set(suite, units);
1386
+ }
1387
+ return units;
1388
+ };
1389
+ // What the file runs when it loads is the same for each of its tests, and often most of what they run.
1390
+ const config = [
1391
+ ...(runsWith.config ? [graph.runUnit(runsWith.config, true)] : []),
1392
+ ...runsWith.modules.map((module) => graph.runUnit(module, false)),
1393
+ ...runsWith.gaps.map((gap) => graph.gapUnit(gap)),
1394
+ ];
1395
+ const base = graph.reach([load, ...config]);
1396
+ // The describes around a test and the helper call that registers it are shared by the tests there, often thousands
1397
+ // for one helper call, so what they reach is walked once for all of them.
1398
+ const contexts = new Map();
1399
+ const contextOf = (scope, registeredBy) => {
1400
+ const byCall = contexts.get(scope) ?? new Map();
1401
+ contexts.set(scope, byCall);
1402
+ let context = byCall.get(registeredBy);
1403
+ if (!context) {
1404
+ const roots = registering(registeredBy);
1405
+ for (let around = scope; around; around = around.parent) {
1406
+ if (around.suite)
1407
+ roots.push(...suiteUnit(around.suite));
1408
+ }
1409
+ context = graph.reach(roots, [base]);
1410
+ byCall.set(registeredBy, context);
1411
+ }
1412
+ return context;
1413
+ };
1414
+ const snapshots = (uses) => {
1415
+ if (!snapshotFiles)
1416
+ return [graph.quietUnit(mainModule, 'Snapshot files are not read when one file is scanned.')];
1417
+ if (snapshotFiles.moved)
1418
+ return [graph.gapUnit(snapshotFiles.moved)];
1419
+ const paths = snapshotPaths(main.relativeFilePath, uses, snapshotFiles.framework);
1420
+ const units = [];
1421
+ const unreadable = () => graph.quietUnit(mainModule, "A snapshot file can't be read, so dependencyHash doesn't cover it.");
1422
+ if (paths.isOutside)
1423
+ units.push(graph.quietUnit(mainModule, "A snapshot path leaves the project, so dependencyHash doesn't cover it."));
1424
+ const files = [...paths.files];
1425
+ for (const [folder, stems] of paths.folders) {
1426
+ const listed = snapshotFiles.list(folder);
1427
+ if (listed === 'unreadable')
1428
+ units.push(unreadable());
1429
+ else
1430
+ files.push(...listed.filter((file) => stems.some((stem) => hasStem(file, stem))).map((file) => `${folder}/${file}`));
1431
+ }
1432
+ for (const file of files) {
1433
+ // A snapshot not written yet is written by the test's first run.
1434
+ const unit = graph.snapshotUnit(file, () => snapshotFiles.read(file));
1435
+ if (unit === 'unreadable')
1436
+ units.push(unreadable());
1437
+ else if (unit)
1438
+ units.push(unit);
1439
+ }
1440
+ return units;
1441
+ };
1442
+ // A test declared in a helper is one call for every test the helper's calls register.
1443
+ const calls = new Map();
1444
+ return (test) => {
1445
+ let own = calls.get(test.call);
1446
+ if (!own) {
1447
+ own = graph.unit(moduleOf(test.frame), [test.call], [], types);
1448
+ calls.set(test.call, own);
1449
+ }
1450
+ const roots = [own];
1451
+ const needs = { names: [...test.declaration.fixtures], all: test.declaration.takesAllFixtures };
1452
+ for (let scope = test.scope; scope; scope = scope.parent) {
1453
+ needs.names.push(...scope.setupFixtures);
1454
+ needs.all ||= scope.takesAllFixtures;
1455
+ }
1456
+ return graph.dependencies(roots, [base, contextOf(test.scope, test.registeredBy)], needs, snapshots, report);
1457
+ };
1458
+ }