test-inventory 0.7.0 → 0.8.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.
@@ -3,7 +3,7 @@ import type { ConfigGap, RunsWith } from './config-dependencies.ts';
3
3
  import type { Extraction, Frame, PendingTest } from './extract.ts';
4
4
  import type { ModuleReader, ModuleSource, Problem, Span } from './model.ts';
5
5
  import { type ParsedFile } from './parse.ts';
6
- import { type SourceText } from './source.ts';
6
+ import { SourceText } from './source.ts';
7
7
  import type { Framework } from './types.ts';
8
8
  import { Values } from './values.ts';
9
9
  /** A file whose code a test can run: the scanned file, a module it imports, or a data file such as JSON. */
@@ -96,6 +96,14 @@ interface Reach {
96
96
  asks: string[] | null;
97
97
  snapshots: SnapshotUse[];
98
98
  }
99
+ /** What several reaches cover together, by file. */
100
+ interface Merged {
101
+ files: string[];
102
+ /** Each file's hashes, sorted. */
103
+ byFile: Map<string, string[]>;
104
+ /** Each file's part of the text the dependency hash is taken of. */
105
+ parts: Map<string, string>;
106
+ }
99
107
  /** A fixture one `extend()` call defines, and the fixtures it asks for; null when it takes them whole. */
100
108
  interface Fixture {
101
109
  unit: Unit;
@@ -145,11 +153,11 @@ export declare class DependencyGraph {
145
153
  /** The units a set of roots reaches, leaving out those `skip` reached, with their hashes by file. */
146
154
  reach(roots: Unit[], skip?: Reach[]): Reach;
147
155
  /**
148
- * Everything a test's roots reach on top of `base`, the code its file runs when loaded, which all its tests share,
149
- * with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
150
- * followed.
156
+ * Everything a test's roots reach on top of `bases`, such as the code its file runs when loaded, which all its tests
157
+ * share, with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
158
+ * followed. `shared` is what the bases cover, put together once for the many tests that share them.
151
159
  */
152
- dependencies(roots: Unit[], bases: Reach[], needs: FixtureNeeds, snapshots: (uses: SnapshotUse[]) => Unit[], report: (place: Place) => void): Dependencies;
160
+ dependencies(roots: Unit[], bases: Reach[], shared: Merged, needs: FixtureNeeds, snapshots: (uses: SnapshotUse[]) => Unit[], report: (place: Place) => void): Dependencies;
153
161
  private newModule;
154
162
  private newUnit;
155
163
  private hashOf;
@@ -5,8 +5,9 @@ import { exportName, parseModule } from "./frameworks/bindings.js";
5
5
  import { destructuredNames, problem, takesWhole } from "./frameworks/common.js";
6
6
  import { shortHash, hashNodes } from "./hash.js";
7
7
  import { parseFile } from "./parse.js";
8
- import { snippet } from "./source.js";
8
+ import { snippet, SourceText } from "./source.js";
9
9
  import { bindingNames, isScopeNode, Values } from "./values.js";
10
+ const NOTHING_MERGED = { files: [], byFile: new Map(), parts: new Map() };
10
11
  /** Statements that only declare something, unless code in them runs while they are evaluated. */
11
12
  const DECLARATIONS = new Set([
12
13
  'ImportDeclaration',
@@ -386,11 +387,11 @@ export class DependencyGraph {
386
387
  return { units, byFile, places, fixtures, asks, snapshots };
387
388
  }
388
389
  /**
389
- * Everything a test's roots reach on top of `base`, the code its file runs when loaded, which all its tests share,
390
- * with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
391
- * followed.
390
+ * Everything a test's roots reach on top of `bases`, such as the code its file runs when loaded, which all its tests
391
+ * share, with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
392
+ * followed. `shared` is what the bases cover, put together once for the many tests that share them.
392
393
  */
393
- dependencies(roots, bases, needs, snapshots, report) {
394
+ dependencies(roots, bases, shared, needs, snapshots, report) {
394
395
  const reached = [...bases, this.reach(roots, bases)];
395
396
  // A fixture can ask for one an object it extends defines, or use another test object, so names are shared by
396
397
  // every test object reached, and each name and object is read once.
@@ -476,11 +477,13 @@ export class DependencyGraph {
476
477
  this.reported.add(key);
477
478
  report(place);
478
479
  }
479
- const files = [...new Set(reached.flatMap((item) => [...item.byFile.keys()]))].sort();
480
+ const added = hashesByFile(reached.slice(bases.length));
481
+ const files = [...new Set([...shared.files, ...added.keys()])].sort();
480
482
  const parts = files.map((file) => {
481
- return [file, reached.flatMap((item) => item.byFile.get(file) ?? []).sort()];
483
+ const own = added.get(file);
484
+ return own ? part(file, [...(shared.byFile.get(file) ?? []), ...own]) : shared.parts.get(file);
482
485
  });
483
- return { hash: shortHash(JSON.stringify(parts)), files, isComplete: places.length === 0 };
486
+ return { hash: shortHash(`[${parts.join(',')}]`), files, isComplete: places.length === 0 };
484
487
  }
485
488
  newModule(relativeFilePath, parsed, text, values, readModule) {
486
489
  return {
@@ -632,7 +635,8 @@ export class DependencyGraph {
632
635
  }
633
636
  /** A place in a module the scan can't follow, as a unit that marks the tests that reach it. */
634
637
  placeUnit(module, at, message, isQuiet = false) {
635
- const { source } = module.parsed;
638
+ // A JSON config, such as Jest's package.json, is taken by its text.
639
+ const source = module.parsed?.source ?? new SourceText(module.text);
636
640
  const unit = this.newUnit(module, [], new Map(), false);
637
641
  unit.edges = new Set();
638
642
  const text = snippet(source.slice(at.start, at.end)) || null;
@@ -665,7 +669,11 @@ export class DependencyGraph {
665
669
  if ('module' in gap) {
666
670
  const module = this.moduleOf(gap.module);
667
671
  // The path a setup key names, where the config writes it.
668
- const at = gap.text === null ? null : literalIn(module.parsed.program, gap.text);
672
+ let at = null;
673
+ if (gap.text !== null && module.parsed)
674
+ at = literalIn(module.parsed.program, gap.text);
675
+ else if (gap.text !== null)
676
+ at = jsonStringIn(module.text, gap.text);
669
677
  return this.placeUnit(module, at ?? { start: 0, end: 0 }, gap.message, gap.isQuiet);
670
678
  }
671
679
  const module = this.newModule(gap.relativeFilePath, null, null, new Values(), null);
@@ -1300,6 +1308,33 @@ export class DependencyGraph {
1300
1308
  return edges;
1301
1309
  }
1302
1310
  }
1311
+ /** The hashes of several reaches by file, in the order the reaches come. */
1312
+ function hashesByFile(reaches) {
1313
+ const byFile = new Map();
1314
+ for (const item of reaches) {
1315
+ for (const [file, hashes] of item.byFile)
1316
+ byFile.set(file, [...(byFile.get(file) ?? []), ...hashes]);
1317
+ }
1318
+ return byFile;
1319
+ }
1320
+ /** A file and its hashes, sorted, as text: the hash is taken of these parts, in the order of the files. */
1321
+ function part(file, hashes) {
1322
+ return JSON.stringify([file, hashes.sort()]);
1323
+ }
1324
+ /** What `merged` covers with what one more reach adds, the parts of the files it adds hashes to written again. */
1325
+ function combine(merged, reach) {
1326
+ if (reach.byFile.size === 0)
1327
+ return merged;
1328
+ const byFile = new Map(merged.byFile);
1329
+ const parts = new Map(merged.parts);
1330
+ for (const [file, hashes] of reach.byFile) {
1331
+ const all = [...(merged.byFile.get(file) ?? []), ...hashes];
1332
+ parts.set(file, part(file, all));
1333
+ byFile.set(file, all);
1334
+ }
1335
+ const files = [...new Set([...merged.files, ...reach.byFile.keys()])].sort();
1336
+ return { files, byFile, parts };
1337
+ }
1303
1338
  /** Whether an identifier refers to a binding, rather than naming a property, a label or a member. */
1304
1339
  function isReference(key, parent) {
1305
1340
  switch (parent?.type) {
@@ -1360,6 +1395,12 @@ function literalIn(node, value) {
1360
1395
  }
1361
1396
  return null;
1362
1397
  }
1398
+ /** Where a JSON file writes a string, as `"./setup.js"`; null when it doesn't. */
1399
+ function jsonStringIn(text, value) {
1400
+ const written = JSON.stringify(value);
1401
+ const start = text.indexOf(written);
1402
+ return start === -1 ? null : { start, end: start + written.length };
1403
+ }
1363
1404
  /**
1364
1405
  * The snapshot files the matchers of a test compare against, from the project root, at the runners' default places:
1365
1406
  * Vitest's `__snapshots__/<file>.snap`, paths given to `toMatchFileSnapshot()`, and browser screenshots in
@@ -1468,6 +1509,7 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1468
1509
  ...runsWith.gaps.map((gap) => graph.gapUnit(gap)),
1469
1510
  ];
1470
1511
  const base = graph.reach([load, ...config]);
1512
+ const baseMerged = combine(NOTHING_MERGED, base);
1471
1513
  // The describes around a test and the helper call that registers it are shared by the tests there, often thousands
1472
1514
  // for one helper call, so what they reach is walked once for all of them.
1473
1515
  const contexts = new Map();
@@ -1481,7 +1523,8 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1481
1523
  if (around.suite)
1482
1524
  roots.push(...suiteUnit(around.suite));
1483
1525
  }
1484
- context = graph.reach(roots, [base]);
1526
+ const reach = graph.reach(roots, [base]);
1527
+ context = { reach, merged: combine(baseMerged, reach) };
1485
1528
  byCall.set(registeredBy, context);
1486
1529
  }
1487
1530
  return context;
@@ -1528,6 +1571,7 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1528
1571
  needs.names.push(...scope.setupFixtures);
1529
1572
  needs.all ||= scope.takesAllFixtures;
1530
1573
  }
1531
- return graph.dependencies(roots, [base, contextOf(test.scope, test.registeredBy)], needs, snapshots, report);
1574
+ const context = contextOf(test.scope, test.registeredBy);
1575
+ return graph.dependencies(roots, [base, context.reach], context.merged, needs, snapshots, report);
1532
1576
  };
1533
1577
  }
@@ -18,6 +18,7 @@ export const LEVELS = {
18
18
  'test-without-body': 'error',
19
19
  'describe-not-called': 'error',
20
20
  'async-describe': 'error',
21
+ 'describe-throws': 'error',
21
22
  'nested-test': 'warning',
22
23
  'tag-without-at': 'error',
23
24
  'removed-api': 'error',
@@ -51,6 +52,7 @@ export const LEVELS = {
51
52
  export const REJECTS_FILE = {
52
53
  playwright: new Set(['test-without-body', 'tag-without-at', 'invalid-chain', 'duplicate-title']),
53
54
  vitest: new Set(['invalid-chain', 'removed-api', 'undefined-tag']),
55
+ jest: new Set(['invalid-chain', 'test-without-body', 'async-describe', 'describe-throws']),
54
56
  };
55
57
  export function toDiagnostic(problem, relativeFilePath, source, testId = null) {
56
58
  const position = problem.node && source ? source.position(problem.node.start) : null;
package/dist/extract.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
2
- import { exportName } from "./frameworks/bindings.js";
2
+ import { commonJsExport, exportName } from "./frameworks/bindings.js";
3
3
  import { problem, readCount } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
5
- import { vitestCaseTitle } from "./case-titles.js";
6
5
  import { isScopeNode } from "./values.js";
7
6
  /** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
8
7
  const MAX_DEPTH = 16;
@@ -363,14 +362,18 @@ class Walker {
363
362
  return;
364
363
  case 'ForInStatement':
365
364
  case 'ForOfStatement': {
366
- const values = leavesEarly(node.body) ? null : this.loopValues(node);
365
+ const leaves = leavesEarly(node.body);
366
+ const values = leaves ? null : this.loopValues(node);
367
367
  const expands = values ? this.expands(node, values, context) : 'no';
368
368
  if (values && expands === 'yes') {
369
369
  this.expandLoop(node, values, context);
370
370
  return;
371
371
  }
372
+ // For `for...in`, the keys are what `Object.keys()` lists.
373
+ const listed = node.type === 'ForOfStatement' && !leaves ? node.right : null;
374
+ const count = values?.length ?? (listed ? this.frame.values.lengthOf(listed, this.scopes.slice(0, -1)) : null);
372
375
  this.walkUnexpanded(node, expands === 'limit', () => {
373
- this.visitChildren(node, this.inLoop(context, values?.length ?? null));
376
+ this.visitChildren(node, this.inLoop(context, count));
374
377
  });
375
378
  return;
376
379
  }
@@ -571,7 +574,8 @@ class Walker {
571
574
  const list = resolved.ok && Array.isArray(resolved.value) ? resolved.value : null;
572
575
  for (const arg of call.arguments) {
573
576
  if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
574
- const values = leavesEarly(arg.body) ? null : list;
577
+ const leaves = leavesEarly(arg.body);
578
+ const values = leaves ? null : list;
575
579
  const expands = values ? this.expands(null, values, context) : 'no';
576
580
  if (values && expands === 'yes') {
577
581
  // The callback gets each element, its index and the array.
@@ -583,8 +587,9 @@ class Walker {
583
587
  }
584
588
  }
585
589
  else {
590
+ const count = values?.length ?? (leaves ? null : this.frame.values.lengthOf(callee.object, this.scopes));
586
591
  this.walkUnexpanded(call, expands === 'limit', () => {
587
- this.visitCallback(arg, this.inLoop(context, values?.length ?? null));
592
+ this.visitCallback(arg, this.inLoop(context, count));
588
593
  });
589
594
  }
590
595
  }
@@ -692,9 +697,11 @@ class Walker {
692
697
  context.test?.problems.push(...api.problems.map((item) => this.here(item)));
693
698
  return true;
694
699
  case 'invalid': {
695
- const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
700
+ // Code that runs after the file loads, or only when a condition holds, can't be sure to stop it loading.
701
+ const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null || context.isInCondition;
696
702
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
697
- return api.declaration ? this.handle(call, api.declaration, context) : true;
703
+ // Under a condition, the call either never runs or fails the file, so it declares nothing either way.
704
+ return api.declaration && !context.isInCondition ? this.handle(call, api.declaration, context) : true;
698
705
  }
699
706
  case 'other':
700
707
  return false;
@@ -793,11 +800,10 @@ class Walker {
793
800
  }
794
801
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
795
802
  importedFunction(name, scopes, from = this.frame) {
796
- const declaration = from.values.declarationOf(name, scopes);
797
803
  const source = from.values.importSource(name, scopes);
798
804
  if (source === null)
799
805
  return null;
800
- const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
806
+ const imported = from.values.importedName(name, scopes);
801
807
  const frame = this.openFrom(from, source);
802
808
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
803
809
  }
@@ -821,14 +827,18 @@ class Walker {
821
827
  return null;
822
828
  searched.add(key);
823
829
  const { program } = frame.parsed;
824
- const local = (localName) => {
825
- const fn = this.functionIn(frame, localName, [program]);
826
- return fn ? { fn, frame } : null;
827
- };
828
830
  const from = (source, exported) => {
829
831
  const module = this.openFrom(frame, source);
830
832
  return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
831
833
  };
834
+ const local = (localName) => {
835
+ const fn = this.functionIn(frame, localName, [program]);
836
+ if (fn)
837
+ return { fn, frame };
838
+ // A name the module imports and exports again, as `import helper from './a'; export { helper }`.
839
+ const source = frame.values.importSource(localName, [program]);
840
+ return source === null ? null : from(source, frame.values.importedName(localName, [program]));
841
+ };
832
842
  for (const statement of program.body) {
833
843
  if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
834
844
  const declaration = statement.declaration;
@@ -857,7 +867,12 @@ class Walker {
857
867
  return statement.source ? from(statement.source.value, localName) : local(localName);
858
868
  }
859
869
  }
860
- return null;
870
+ // A CommonJS module: `module.exports = { registerSettlement }` or `exports.registerSettlement = function () {}`.
871
+ const commonJs = commonJsExport(program, name);
872
+ const value = commonJs ? unwrap(commonJs) : null;
873
+ if (value?.type === 'Identifier')
874
+ return local(value.name);
875
+ return isFunctionNode(value) ? { fn: value, frame } : null;
861
876
  }
862
877
  /**
863
878
  * Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
@@ -946,6 +961,18 @@ class Walker {
946
961
  * called instead.
947
962
  */
948
963
  runSetting(call, api, context) {
964
+ if (api.fileWide) {
965
+ for (const item of api.problems)
966
+ this.report(item);
967
+ // Called by a function or while tests run, it changes only the tests that run after it.
968
+ if (context.declaredIn !== null || context.test || context.hookScope)
969
+ return;
970
+ const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
971
+ for (const item of problems)
972
+ this.report(item);
973
+ mergeRun(this.fileScope.details.run, settings);
974
+ return;
975
+ }
949
976
  if (context.declaredIn !== null && !context.test) {
950
977
  for (const item of api.problems)
951
978
  this.report(item);
@@ -984,11 +1011,18 @@ class Walker {
984
1011
  conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
985
1012
  };
986
1013
  }
987
- if (settings.timeout !== undefined && guard !== null) {
988
- const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
1014
+ const unapplied = (key) => {
1015
+ const message = `Option ${key}: ${argument} is set only when ${guard}; it is not applied.`;
989
1016
  problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
1017
+ };
1018
+ if (settings.timeout !== undefined && guard !== null) {
1019
+ unapplied('timeout');
990
1020
  delete settings.timeout;
991
1021
  }
1022
+ if (settings.retry !== undefined && guard !== null) {
1023
+ unapplied('retry');
1024
+ delete settings.retry;
1025
+ }
992
1026
  return { settings, problems };
993
1027
  }
994
1028
  /** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
@@ -1198,9 +1232,9 @@ class Walker {
1198
1232
  const title = declaration.title;
1199
1233
  if (!title || title.isDynamic)
1200
1234
  return single("the scan can't work out the title");
1201
- const titles = declaration.cases.map((item, index) => vitestCaseTitle(title.text, item.items, index, this.options.version));
1235
+ const titles = declaration.cases.map((item) => item.title);
1202
1236
  if (titles.some((text) => text === null)) {
1203
- return single("the title shows an array or object, which the scan doesn't format the way Vitest does");
1237
+ return single("the title shows a value the scan doesn't format the way the runner does");
1204
1238
  }
1205
1239
  if (declaration.kind === 'describe' && this.expands(null, declaration.cases, context) === 'limit') {
1206
1240
  return single(`expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases`);
package/dist/files.js CHANGED
@@ -82,8 +82,9 @@ export async function findFiles(root, patterns, ignore, framework, platform = pr
82
82
  const absolutePath = path.resolve(root, match);
83
83
  const realPath = realpathSync(absolutePath);
84
84
  const linked = realPath !== path.join(realRoot, match);
85
- if (linked && framework === 'playwright') {
86
- diagnostics.push(fileDiagnostic('symlink-ignored', match, 'The file is reached through a symbolic link, which Playwright does not follow.'));
85
+ if (linked && framework !== 'vitest') {
86
+ const name = framework === 'jest' ? 'Jest' : 'Playwright';
87
+ diagnostics.push(fileDiagnostic('symlink-ignored', match, `The file is reached through a symbolic link, which ${name} does not follow.`));
87
88
  continue;
88
89
  }
89
90
  candidates.push({ absolutePath, relativeFilePath: match, realPath, linked });
@@ -1,4 +1,6 @@
1
1
  import type { Framework } from './types.ts';
2
+ /** Each framework's name in messages. */
3
+ export declare const FRAMEWORK_NAMES: Record<Framework, string>;
2
4
  /** Oldest supported version of each framework. */
3
5
  export declare const OLDEST: Record<Framework, [number, number]>;
4
6
  /** Where the framework version came from. */
@@ -16,3 +18,10 @@ export interface DetectedVersion {
16
18
  * below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
17
19
  */
18
20
  export declare function detectFrameworkVersion(root: string, framework: Framework, files: readonly string[]): DetectedVersion | null;
21
+ /**
22
+ * The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
23
+ * folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
24
+ * root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
25
+ * none of them or several, naming what it found.
26
+ */
27
+ export declare function detectFramework(root: string, ignore: readonly string[]): Framework;
@@ -1,9 +1,12 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globSync } from 'tinyglobby';
4
- const PACKAGES = { playwright: '@playwright/test', vitest: 'vitest' };
4
+ import { OptionsError } from "./options.js";
5
+ const PACKAGES = { playwright: '@playwright/test', vitest: 'vitest', jest: 'jest' };
6
+ /** Each framework's name in messages. */
7
+ export const FRAMEWORK_NAMES = { playwright: 'Playwright', vitest: 'Vitest', jest: 'Jest' };
5
8
  /** Oldest supported version of each framework. */
6
- export const OLDEST = { playwright: [1, 42], vitest: [3, 0] };
9
+ export const OLDEST = { playwright: [1, 42], vitest: [3, 0], jest: [29, 0] };
7
10
  /** A file's text, or null when it is missing or can't be read. */
8
11
  function readText(file) {
9
12
  try {
@@ -108,3 +111,45 @@ function detectIn(root, all, name) {
108
111
  }
109
112
  return null;
110
113
  }
114
+ /** Config file names of each runner. A Vite config alone doesn't count, as Vite apps without Vitest have one. */
115
+ const CONFIG_NAMES = {
116
+ playwright: /^playwright\.config\.[cm]?[jt]s$/,
117
+ vitest: /^vitest\.config\.[cm]?[jt]s$/,
118
+ jest: /^jest\.config\.([cm]?[jt]s|json)$/,
119
+ };
120
+ /** Whether a package.json lists a runner, or for Jest has a `jest` key, its config. */
121
+ function declares(manifest, framework) {
122
+ const json = readJson(manifest);
123
+ const fields = ['devDependencies', 'dependencies', 'peerDependencies', 'optionalDependencies'];
124
+ return (fields.some((field) => json?.[field]?.[PACKAGES[framework]] !== undefined) ||
125
+ (framework === 'jest' && json?.jest !== undefined));
126
+ }
127
+ /**
128
+ * The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
129
+ * folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
130
+ * root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
131
+ * none of them or several, naming what it found.
132
+ */
133
+ export function detectFramework(root, ignore) {
134
+ const below = globSync(['**/package.json', '**/*.config.*'], {
135
+ cwd: root,
136
+ ignore: ['**/node_modules/**', '**/.git/**', ...ignore],
137
+ });
138
+ const manifests = below.filter((file) => path.basename(file) === 'package.json').map((file) => path.join(root, file));
139
+ const above = folders(root)
140
+ .slice(1)
141
+ .map((folder) => path.join(folder, 'package.json'));
142
+ const all = ['playwright', 'vitest', 'jest'];
143
+ let found = all.filter((framework) => below.some((file) => CONFIG_NAMES[framework].test(path.basename(file))) ||
144
+ manifests.some((manifest) => declares(manifest, framework)));
145
+ if (found.length === 0)
146
+ found = all.filter((framework) => above.some((manifest) => declares(manifest, framework)));
147
+ if (found.length === 1)
148
+ return found[0];
149
+ if (found.length === 0) {
150
+ throw new OptionsError('options.framework is needed: no package.json in the root, below it or above it lists @playwright/test, vitest or jest, and no config of theirs is in the root or below.');
151
+ }
152
+ const names = found.map((framework) => FRAMEWORK_NAMES[framework]);
153
+ const values = found.map((framework) => `"${framework}"`);
154
+ throw new OptionsError(`options.framework is needed, as the project uses ${names.slice(0, -1).join(', ')} and ${names.at(-1)}: scan it once with each of ${values.join(', ')}.`);
155
+ }
@@ -19,6 +19,8 @@ export interface TestObjects {
19
19
  returnsTestObject(name: string): boolean;
20
20
  /** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
21
21
  reexports(source: string, imported: string, exported: Exported): boolean;
22
+ /** What the test object the module exports as `name` stands for, as `it.skip` stands for `it` with `skip`. */
23
+ exportedStart?(name: string): unknown;
22
24
  }
23
25
  /**
24
26
  * The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
@@ -54,6 +56,13 @@ export declare function allDeclarators(program: Program): VariableDeclarator[];
54
56
  export declare function moduleImports(program: Program, isFramework: (source: string) => boolean): Map<string, ModuleImport>;
55
57
  /** The module of `require('module')`, or null for any other expression. */
56
58
  export declare function requiredModule(expression: Expression): string | null;
59
+ /**
60
+ * What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
61
+ * `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
62
+ */
63
+ export declare function commonJsExport(program: Program, name: string): Expression | null;
64
+ /** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
65
+ export declare function commonJsNames(program: Program): string[];
57
66
  /** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
58
67
  export declare function moduleExportsValue(statement: Node): Expression | null;
59
68
  /** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
@@ -103,10 +112,15 @@ export declare function followModule(source: string, name: string, readModule: M
103
112
  interface Followed {
104
113
  exported: boolean;
105
114
  reason: string | null;
115
+ /** What the exported test object stands for, when the framework's test objects say. */
116
+ start?: unknown;
117
+ /** The module writes a function under the name, a helper that registers tests itself, which the walk follows. */
118
+ isHelper?: boolean;
106
119
  }
107
120
  /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
108
121
  export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
109
122
  exported: boolean;
110
123
  problem: Problem | null;
124
+ start: unknown;
111
125
  };
112
126
  export {};
@@ -1,4 +1,4 @@
1
- import { childNodes, isFunctionNode, propertyName, returnValues, unwrap } from "../ast.js";
1
+ import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, unwrap, } from "../ast.js";
2
2
  import { parseFile } from "../parse.js";
3
3
  import { snippet } from "../source.js";
4
4
  import { problem, text } from "./common.js";
@@ -125,6 +125,18 @@ export function moduleImports(program, isFramework) {
125
125
  imports.set(specifier.local.name, { source: statement.source.value, imported, node: specifier });
126
126
  }
127
127
  }
128
+ // `const { test } = require('./fixtures')` imports as `import { test } from './fixtures'` does.
129
+ for (const declarator of topLevelDeclarators(program)) {
130
+ const source = declarator.init ? requiredModule(declarator.init) : null;
131
+ if (source === null || !/^\.\.?\//.test(source) || declarator.id.type !== 'ObjectPattern')
132
+ continue;
133
+ for (const property of declarator.id.properties) {
134
+ const key = property.type === 'Property' ? propertyKey(property) : null;
135
+ if (key !== null && property.type === 'Property' && property.value.type === 'Identifier') {
136
+ imports.set(property.value.name, { source, imported: key, node: declarator });
137
+ }
138
+ }
139
+ }
128
140
  return imports;
129
141
  }
130
142
  /** The module of `require('module')`, or null for any other expression. */
@@ -135,6 +147,59 @@ export function requiredModule(expression) {
135
147
  const argument = node.arguments.at(0);
136
148
  return argument?.type === 'Literal' && typeof argument.value === 'string' ? argument.value : null;
137
149
  }
150
+ /**
151
+ * What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
152
+ * `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
153
+ */
154
+ export function commonJsExport(program, name) {
155
+ let found = null;
156
+ for (const statement of program.body) {
157
+ const whole = moduleExportsValue(statement);
158
+ if (whole) {
159
+ const object = unwrap(whole);
160
+ if (name === 'default')
161
+ found = whole;
162
+ else if (object.type === 'ObjectExpression') {
163
+ for (const property of object.properties) {
164
+ if (property.type === 'Property' && propertyKey(property) === name)
165
+ found = property.value;
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+ if (statement.type !== 'ExpressionStatement')
171
+ continue;
172
+ const expression = unwrap(statement.expression);
173
+ if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
174
+ continue;
175
+ if (propertyName(expression.left) !== name)
176
+ continue;
177
+ const target = unwrap(expression.left.object);
178
+ const isExports = target.type === 'Identifier' && target.name === 'exports';
179
+ const isModuleExports = target.type === 'MemberExpression' &&
180
+ target.object.type === 'Identifier' &&
181
+ target.object.name === 'module' &&
182
+ propertyName(target) === 'exports';
183
+ if (isExports || isModuleExports)
184
+ found = expression.right;
185
+ }
186
+ return found;
187
+ }
188
+ /** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
189
+ export function commonJsNames(program) {
190
+ const names = new Set();
191
+ for (const statement of program.body) {
192
+ if (statement.type !== 'ExpressionStatement')
193
+ continue;
194
+ const expression = unwrap(statement.expression);
195
+ if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
196
+ continue;
197
+ const name = propertyName(expression.left);
198
+ if (name !== null && commonJsExport(program, name) === expression.right)
199
+ names.add(name);
200
+ }
201
+ return [...names];
202
+ }
138
203
  /** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
139
204
  export function moduleExportsValue(statement) {
140
205
  if (statement.type !== 'ExpressionStatement')
@@ -187,6 +252,9 @@ export function exportsTestObject(program, name, objects, exported = 'test objec
187
252
  case 'specifier':
188
253
  return own.source === null ? local(own.local) : objects.reexports(own.source, own.local, exported);
189
254
  }
255
+ const commonJs = exported === 'test object' && name !== 'default' ? commonJsExport(program, name) : null;
256
+ if (commonJs)
257
+ return objects.isTestObject(commonJs);
190
258
  return program.body.some((statement) => {
191
259
  const source = starReexport(statement);
192
260
  return source !== null && objects.reexports(source, name, exported);
@@ -382,12 +450,21 @@ function followExports(module, name, framework, exported, within, followed) {
382
450
  return { exported: false, reason: 'could not be parsed' };
383
451
  const followFrom = (from, imported, kind) => followModule(from, imported, module.readModule, framework, kind, within, followed);
384
452
  const objects = framework.testObjects(program, (from, imported, kind = 'test object') => followFrom(from, imported, kind).exported);
385
- if (exportsTestObject(program, name, objects, exported))
386
- return { exported: true, reason: null };
453
+ if (exportsTestObject(program, name, objects, exported)) {
454
+ return { exported: true, reason: null, start: objects.exportedStart?.(name) };
455
+ }
456
+ // A function the module writes and exports is a helper that registers tests itself, which the walk follows.
457
+ if (exported === 'test object' && exportsFunction(program, name)) {
458
+ return { exported: false, reason: null, isHelper: true };
459
+ }
387
460
  const unrecognized = (kind) => `does not export ${kind === 'function' ? 'a function that returns a test object' : `a ${framework.name} test object`} that could be recognized`;
388
461
  const origin = importOrigin(program, name, framework.isSource, exported);
389
462
  if (origin) {
390
- const end = followFrom(origin.source, origin.imported, origin.exported).reason ?? unrecognized(origin.exported);
463
+ const followed = followFrom(origin.source, origin.imported, origin.exported);
464
+ // A helper function re-exported, as `import helper from './a'; export { helper }`, is no broken test object.
465
+ if (followed.isHelper && origin.how === 're-exports')
466
+ return followed;
467
+ const end = followed.reason ?? unrecognized(origin.exported);
391
468
  return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
392
469
  }
393
470
  if (importsFrom(program, framework.isSource))
@@ -397,6 +474,22 @@ function followExports(module, name, framework, exported, within, followed) {
397
474
  const call = exported === 'test object' ? creatingCall(program, name, source) : null;
398
475
  return { exported: false, reason: call ? `builds it with ${call}, which is not followed` : null };
399
476
  }
477
+ /** Whether a module writes the function it exports as `name`: declared, written in place, or held in a variable. */
478
+ function exportsFunction(program, name) {
479
+ const own = ownExport(program, name);
480
+ if (own?.kind === 'function')
481
+ return true;
482
+ if (own?.kind === 'default') {
483
+ const value = unwrap(own.value);
484
+ return value.type === 'FunctionDeclaration' || isFunctionNode(value);
485
+ }
486
+ const local = own?.kind === 'variable' ? name : own?.kind === 'specifier' && own.source === null ? own.local : null;
487
+ if (local === null)
488
+ return false;
489
+ const declared = program.body.some((statement) => statement.type === 'FunctionDeclaration' && statement.id?.name === local);
490
+ const declarator = topLevelDeclarators(program).find((item) => item.id.type === 'Identifier' && item.id.name === local);
491
+ return declared || isFunctionNode(declarator?.init ? unwrap(declarator.init) : null);
492
+ }
400
493
  /**
401
494
  * The call that creates what the module exports as `name`, a variable or its default export, as `defineBrokerTest()`;
402
495
  * null for other values.
@@ -412,9 +505,9 @@ function creatingCall(program, name, source) {
412
505
  }
413
506
  /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
414
507
  export function followImport(name, imported, readModule, file, framework) {
415
- const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
508
+ const { exported, reason, start } = followModule(imported.source, imported.imported, readModule, framework);
416
509
  const problemFound = reason
417
510
  ? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
418
511
  : null;
419
- return { exported, problem: problemFound };
512
+ return { exported, problem: problemFound, start };
420
513
  }