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.
- package/CHANGELOG.md +51 -0
- package/README.md +4 -3
- package/dist/case-titles.d.ts +13 -0
- package/dist/case-titles.js +102 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -5
- package/dist/config-dependencies.js +8 -3
- package/dist/config-runs.d.ts +22 -0
- package/dist/config-runs.js +263 -3
- package/dist/config.d.ts +18 -1
- package/dist/config.js +160 -14
- package/dist/dependency-hash.d.ts +13 -5
- package/dist/dependency-hash.js +56 -12
- package/dist/diagnostics.js +2 -0
- package/dist/extract.js +53 -19
- package/dist/files.js +3 -2
- package/dist/framework-version.d.ts +9 -0
- package/dist/framework-version.js +47 -2
- package/dist/frameworks/bindings.d.ts +14 -0
- package/dist/frameworks/bindings.js +99 -6
- package/dist/frameworks/common.js +7 -0
- package/dist/frameworks/jest.d.ts +46 -0
- package/dist/frameworks/jest.js +442 -0
- package/dist/frameworks/test-functions.d.ts +169 -0
- package/dist/frameworks/test-functions.js +492 -0
- package/dist/frameworks/vitest.d.ts +7 -62
- package/dist/frameworks/vitest.js +37 -388
- package/dist/imported-values.js +15 -1
- package/dist/model.d.ts +5 -2
- package/dist/options.d.ts +5 -1
- package/dist/options.js +2 -2
- package/dist/parse.js +17 -2
- package/dist/records.js +46 -11
- package/dist/resolve.js +39 -36
- package/dist/scan-file.js +7 -3
- package/dist/scan.js +21 -6
- package/dist/types.d.ts +10 -4
- package/dist/values.d.ts +10 -0
- package/dist/values.js +107 -2
- package/package.json +1 -1
- package/schema/v1.json +5 -3
|
@@ -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 {
|
|
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 `
|
|
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;
|
package/dist/dependency-hash.js
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
988
|
-
const message = `Option
|
|
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
|
|
1235
|
+
const titles = declaration.cases.map((item) => item.title);
|
|
1202
1236
|
if (titles.some((text) => text === null)) {
|
|
1203
|
-
return single("the title shows
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|