test-inventory 0.6.1 → 0.7.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 +49 -0
- package/dist/case-titles.d.ts +14 -0
- package/dist/case-titles.js +227 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/diagnostics.js +1 -0
- package/dist/extract.d.ts +19 -1
- package/dist/extract.js +345 -29
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +4 -2
- package/dist/framework-version.js +17 -3
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +20 -0
- package/dist/frameworks/playwright.d.ts +3 -1
- package/dist/frameworks/playwright.js +14 -5
- package/dist/frameworks/vitest.d.ts +11 -1
- package/dist/frameworks/vitest.js +68 -11
- package/dist/imported-values.d.ts +22 -0
- package/dist/imported-values.js +135 -0
- package/dist/model.d.ts +8 -0
- package/dist/options.d.ts +11 -0
- package/dist/options.js +7 -0
- package/dist/records.js +11 -4
- package/dist/scan-file.d.ts +5 -0
- package/dist/scan-file.js +8 -5
- package/dist/scan-source.js +3 -1
- package/dist/scan.js +10 -4
- package/dist/types.d.ts +15 -3
- package/dist/values.d.ts +39 -1
- package/dist/values.js +425 -18
- package/package.json +1 -1
- package/schema/v1.json +28 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
3
|
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
|
|
4
|
-
import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
4
|
+
import { atLeast, boundMembers, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
5
5
|
const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
|
|
6
6
|
// Maps rather than object literals, so names such as `constructor` don't match inherited properties.
|
|
7
7
|
const DESCRIBES = new Map([
|
|
@@ -297,7 +297,9 @@ export class PlaywrightAdapter {
|
|
|
297
297
|
const declaration = this.file.values.declarationOf(base.name, scopes);
|
|
298
298
|
const bound = declaration !== null && this.bindings.declarations.has(declaration);
|
|
299
299
|
const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
|
|
300
|
-
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
|
|
300
|
+
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
|
|
301
|
+
? this.alias(declaration, base.name, lookup)
|
|
302
|
+
: null;
|
|
301
303
|
// The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
|
|
302
304
|
if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
|
|
303
305
|
return links.slice(1);
|
|
@@ -366,9 +368,14 @@ export class PlaywrightAdapter {
|
|
|
366
368
|
}
|
|
367
369
|
/**
|
|
368
370
|
* The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
|
|
369
|
-
* test.describe.serial`,
|
|
371
|
+
* test.describe.serial`, none for a test object a function returns, and the member `name` takes when it is
|
|
372
|
+
* destructured, as in `const { beforeEach } = test`. Playwright binds every member to its test object, so a
|
|
373
|
+
* destructured one works as it does on the object; a test object built with a destructured `extend` is not followed.
|
|
370
374
|
*/
|
|
371
|
-
alias(declarator, lookup) {
|
|
375
|
+
alias(declarator, name, lookup) {
|
|
376
|
+
const destructured = boundMembers(declarator.id, name);
|
|
377
|
+
if (!destructured)
|
|
378
|
+
return null;
|
|
372
379
|
let members = this.aliases.get(declarator);
|
|
373
380
|
if (members === undefined) {
|
|
374
381
|
// Set first, so a variable defined through itself ends the lookup.
|
|
@@ -376,7 +383,7 @@ export class PlaywrightAdapter {
|
|
|
376
383
|
members = declarator.init ? this.members(declarator.init, lookup) : null;
|
|
377
384
|
this.aliases.set(declarator, members);
|
|
378
385
|
}
|
|
379
|
-
return members;
|
|
386
|
+
return members && [...members, ...destructured];
|
|
380
387
|
}
|
|
381
388
|
/** The members of `test` a value holds: none for a test object, or those of a describe function. */
|
|
382
389
|
members(expression, lookup) {
|
|
@@ -480,6 +487,7 @@ export class PlaywrightAdapter {
|
|
|
480
487
|
hashed,
|
|
481
488
|
isParameterized: false,
|
|
482
489
|
caseCount: null,
|
|
490
|
+
cases: null,
|
|
483
491
|
testContext: null,
|
|
484
492
|
fixtures: destructuredNames(hashed, 0),
|
|
485
493
|
takesAllFixtures: takesWhole(hashed, 0),
|
|
@@ -545,6 +553,7 @@ export class PlaywrightAdapter {
|
|
|
545
553
|
hashed: fn,
|
|
546
554
|
isParameterized: false,
|
|
547
555
|
caseCount: null,
|
|
556
|
+
cases: null,
|
|
548
557
|
problems,
|
|
549
558
|
};
|
|
550
559
|
}
|
|
@@ -37,7 +37,11 @@ export declare class VitestAdapter implements Adapter {
|
|
|
37
37
|
*/
|
|
38
38
|
private resolve;
|
|
39
39
|
private start;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
|
|
42
|
+
* takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
|
|
43
|
+
* `each` and `skipIf`, return a test function only when called, so their calls are never read through a variable.
|
|
44
|
+
*/
|
|
41
45
|
private alias;
|
|
42
46
|
private aliasOf;
|
|
43
47
|
/**
|
|
@@ -79,6 +83,12 @@ export declare class VitestAdapter implements Adapter {
|
|
|
79
83
|
private retry;
|
|
80
84
|
private meta;
|
|
81
85
|
private caseCount;
|
|
86
|
+
/**
|
|
87
|
+
* The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
|
|
88
|
+
* array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
|
|
89
|
+
* gives one object per row, keyed by the names in its first line.
|
|
90
|
+
*/
|
|
91
|
+
private tableCases;
|
|
82
92
|
private arrayLength;
|
|
83
93
|
private tagsUsed;
|
|
84
94
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
3
|
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
|
|
4
|
-
import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
|
|
4
|
+
import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, boundMembers, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
|
|
5
5
|
/** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
|
|
6
6
|
function shared(a, b) {
|
|
7
7
|
if (!a || a.kind !== b?.kind)
|
|
@@ -402,7 +402,7 @@ export class VitestAdapter {
|
|
|
402
402
|
this.worthFollowing(base.name, links, args) &&
|
|
403
403
|
this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
|
|
404
404
|
if (!followed && !this.bindings.declarations.has(declaration)) {
|
|
405
|
-
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
|
|
405
|
+
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, base.name, lookup) : null;
|
|
406
406
|
if (alias)
|
|
407
407
|
return { kind: alias.kind, index: 0, modifiers: alias.links };
|
|
408
408
|
return this.assumeLocal(declaration, base.name, links, args)
|
|
@@ -416,8 +416,15 @@ export class VitestAdapter {
|
|
|
416
416
|
const kind = this.bindings.kinds.get(base.name);
|
|
417
417
|
return kind ? { kind, index: 0 } : null;
|
|
418
418
|
}
|
|
419
|
-
/**
|
|
420
|
-
|
|
419
|
+
/**
|
|
420
|
+
* The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
|
|
421
|
+
* takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
|
|
422
|
+
* `each` and `skipIf`, return a test function only when called, so their calls are never read through a variable.
|
|
423
|
+
*/
|
|
424
|
+
alias(declarator, name, lookup) {
|
|
425
|
+
const destructured = boundMembers(declarator.id, name);
|
|
426
|
+
if (!destructured)
|
|
427
|
+
return null;
|
|
421
428
|
let alias = this.aliases.get(declarator);
|
|
422
429
|
if (alias === undefined) {
|
|
423
430
|
// Set first, so a variable defined through itself ends the lookup.
|
|
@@ -425,7 +432,8 @@ export class VitestAdapter {
|
|
|
425
432
|
alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
|
|
426
433
|
this.aliases.set(declarator, alias);
|
|
427
434
|
}
|
|
428
|
-
|
|
435
|
+
const links = destructured.map((member) => ({ name: member, args: null, template: null }));
|
|
436
|
+
return alias && { kind: alias.kind, links: [...alias.links, ...links] };
|
|
429
437
|
}
|
|
430
438
|
aliasOf(expression, lookup) {
|
|
431
439
|
const node = unwrap(expression);
|
|
@@ -695,6 +703,7 @@ export class VitestAdapter {
|
|
|
695
703
|
hashed: fn ?? namedFunction(this.file, context.scopes, body),
|
|
696
704
|
isParameterized: table !== null,
|
|
697
705
|
caseCount: table ? this.caseCount(call, table, context, problems) : null,
|
|
706
|
+
cases: table ? this.tableCases(table, context) : null,
|
|
698
707
|
problems,
|
|
699
708
|
};
|
|
700
709
|
let declared;
|
|
@@ -887,6 +896,43 @@ export class VitestAdapter {
|
|
|
887
896
|
}
|
|
888
897
|
return count;
|
|
889
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
|
|
901
|
+
* array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
|
|
902
|
+
* gives one object per row, keyed by the names in its first line.
|
|
903
|
+
*/
|
|
904
|
+
tableCases(table, context) {
|
|
905
|
+
let cases;
|
|
906
|
+
if (table.template) {
|
|
907
|
+
const values = table.template.expressions.map((item) => this.file.values.resolve(item, context.scopes));
|
|
908
|
+
if (!values.every((item) => item.ok))
|
|
909
|
+
return null;
|
|
910
|
+
const header = templateHeader(table.template);
|
|
911
|
+
// Without values, Vitest passes the template's strings as the table.
|
|
912
|
+
cases =
|
|
913
|
+
values.length === 0
|
|
914
|
+
? table.template.quasis.map((quasi) => quasi.value.cooked ?? undefined)
|
|
915
|
+
: Array.from({ length: Math.floor(values.length / header.length) }, (_, row) => Object.fromEntries(header.map((name, column) => [name, values[row * header.length + column].value])));
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
const first = table.args.at(0);
|
|
919
|
+
const resolved = first && first.type !== 'SpreadElement' ? this.file.values.resolve(first, context.scopes) : null;
|
|
920
|
+
if (!resolved?.ok || !Array.isArray(resolved.value))
|
|
921
|
+
return null;
|
|
922
|
+
cases = resolved.value;
|
|
923
|
+
}
|
|
924
|
+
if (table.name === 'for') {
|
|
925
|
+
return cases.map((item) => ({
|
|
926
|
+
items: item === null || item === undefined ? [] : Array.isArray(item) ? item : [item],
|
|
927
|
+
args: [item],
|
|
928
|
+
}));
|
|
929
|
+
}
|
|
930
|
+
const arraysOnly = cases.every((item) => Array.isArray(item));
|
|
931
|
+
return cases.map((item) => {
|
|
932
|
+
const items = Array.isArray(item) ? item : [item];
|
|
933
|
+
return { items, args: arraysOnly ? items : [item] };
|
|
934
|
+
});
|
|
935
|
+
}
|
|
890
936
|
arrayLength(node, context) {
|
|
891
937
|
if (node === undefined || node.type === 'SpreadElement')
|
|
892
938
|
return null;
|
|
@@ -908,13 +954,24 @@ export class VitestAdapter {
|
|
|
908
954
|
this.problems.push(problem('config-may-change-state', node, "The file uses Vitest tags, and tags defined in the Vitest config can skip tests or change how they run; the scan can't work out the config's tags."));
|
|
909
955
|
}
|
|
910
956
|
}
|
|
911
|
-
/**
|
|
957
|
+
/**
|
|
958
|
+
* The column names of a tagged-template table such as ``each`side | qty ${'buy'} | ${100}` ``, as Vitest's
|
|
959
|
+
* `formatTemplateString` reads them: the text of the template with its spaces taken out, its first line split at `|`.
|
|
960
|
+
* Outer pipes, as in `| side | qty |`, give empty names too.
|
|
961
|
+
*/
|
|
962
|
+
function templateHeader(template) {
|
|
963
|
+
// A string with an invalid escape, as in `\xZZ`, has no cooked value, which joins as nothing.
|
|
964
|
+
const strings = template.quasis.map((quasi) => quasi.value.cooked ?? '');
|
|
965
|
+
return strings.join('').trim().replace(/ /g, '').split('\n')[0].split('|');
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Rows of a tagged-template table: as many as the values fill, an incomplete last row left out, as Vitest does. A
|
|
969
|
+
* template without values isn't split, so its text is the one case.
|
|
970
|
+
*/
|
|
912
971
|
function templateRows(template) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
return null;
|
|
917
|
-
return template.expressions.length / columns;
|
|
972
|
+
if (template.expressions.length === 0)
|
|
973
|
+
return 1;
|
|
974
|
+
return Math.floor(template.expressions.length / templateHeader(template).length);
|
|
918
975
|
}
|
|
919
976
|
function isMetaValue(value) {
|
|
920
977
|
return value !== undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ModuleReader } from './model.ts';
|
|
2
|
+
import { type ImportReader } from './values.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Works out the values project modules export, for the files of one scan: each module is parsed once and each of its
|
|
5
|
+
* exports worked out once, however many test files import it. Installed packages are never read, since the reader
|
|
6
|
+
* only gives project files.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ExportedValues {
|
|
9
|
+
private readonly modules;
|
|
10
|
+
private readonly done;
|
|
11
|
+
/** Results of the current lookup, also those that `done` can't keep, so that each module is read once in it. */
|
|
12
|
+
private readonly current;
|
|
13
|
+
private readonly pending;
|
|
14
|
+
/** How many times a lookup was cut short by a cycle or the depth limit. */
|
|
15
|
+
private cutShort;
|
|
16
|
+
/** What a file that reads its imports with `readModule` gets for `import { name } from source`. */
|
|
17
|
+
reader(readModule: ModuleReader | null): ImportReader | null;
|
|
18
|
+
/** What `module` exports under `name`; null when it doesn't export it. */
|
|
19
|
+
private exported;
|
|
20
|
+
private read;
|
|
21
|
+
private open;
|
|
22
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ownExport, parseModule, starReexport } from "./frameworks/bindings.js";
|
|
2
|
+
import { Values } from "./values.js";
|
|
3
|
+
const UNRESOLVED = { ok: false, importedFrom: null };
|
|
4
|
+
/** How many modules deep a name is followed through re-exports and imports, as for test objects. */
|
|
5
|
+
const MAX_DEPTH = 16;
|
|
6
|
+
/**
|
|
7
|
+
* Works out the values project modules export, for the files of one scan: each module is parsed once and each of its
|
|
8
|
+
* exports worked out once, however many test files import it. Installed packages are never read, since the reader
|
|
9
|
+
* only gives project files.
|
|
10
|
+
*/
|
|
11
|
+
export class ExportedValues {
|
|
12
|
+
modules = new WeakMap();
|
|
13
|
+
done = new Map();
|
|
14
|
+
/** Results of the current lookup, also those that `done` can't keep, so that each module is read once in it. */
|
|
15
|
+
current = new Map();
|
|
16
|
+
pending = new Set();
|
|
17
|
+
/** How many times a lookup was cut short by a cycle or the depth limit. */
|
|
18
|
+
cutShort = 0;
|
|
19
|
+
/** What a file that reads its imports with `readModule` gets for `import { name } from source`. */
|
|
20
|
+
reader(readModule) {
|
|
21
|
+
if (!readModule)
|
|
22
|
+
return null;
|
|
23
|
+
return (source, name) => {
|
|
24
|
+
const module = readModule(source);
|
|
25
|
+
if ('reason' in module)
|
|
26
|
+
return UNRESOLVED;
|
|
27
|
+
return this.exported(module, name) ?? UNRESOLVED;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** What `module` exports under `name`; null when it doesn't export it. */
|
|
31
|
+
exported(module, name) {
|
|
32
|
+
const key = `${module.relativeFilePath}\0${name}`;
|
|
33
|
+
if (this.done.has(key))
|
|
34
|
+
return this.done.get(key);
|
|
35
|
+
if (this.current.has(key)) {
|
|
36
|
+
// It was worked out on a path that was cut short, so what is built on it depends on that path too.
|
|
37
|
+
this.cutShort++;
|
|
38
|
+
return this.current.get(key);
|
|
39
|
+
}
|
|
40
|
+
// A path that comes back to a name being worked out, as `export *` modules that re-export each other do, gives
|
|
41
|
+
// nothing; another path may still give it.
|
|
42
|
+
if (this.pending.has(key) || this.pending.size >= MAX_DEPTH) {
|
|
43
|
+
this.cutShort++;
|
|
44
|
+
return this.pending.has(key) ? null : UNRESOLVED;
|
|
45
|
+
}
|
|
46
|
+
const before = this.cutShort;
|
|
47
|
+
this.pending.add(key);
|
|
48
|
+
const result = this.read(module, name);
|
|
49
|
+
this.pending.delete(key);
|
|
50
|
+
// A result found while a path was cut short depends on where the lookup began, so only this lookup keeps it.
|
|
51
|
+
if (this.cutShort === before)
|
|
52
|
+
this.done.set(key, result);
|
|
53
|
+
else
|
|
54
|
+
this.current.set(key, result);
|
|
55
|
+
if (this.pending.size === 0)
|
|
56
|
+
this.current.clear();
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
read(module, name) {
|
|
60
|
+
const opened = this.open(module);
|
|
61
|
+
if (!opened)
|
|
62
|
+
return UNRESOLVED;
|
|
63
|
+
if (opened.json)
|
|
64
|
+
return jsonExport(opened.json.value, name);
|
|
65
|
+
const program = opened.program;
|
|
66
|
+
for (const statement of program.body) {
|
|
67
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : null;
|
|
68
|
+
if (declaration?.type === 'TSEnumDeclaration' && declaration.id.name === name) {
|
|
69
|
+
return opened.values.resolveName(name, [program]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const own = ownExport(program, name);
|
|
73
|
+
if (own?.kind === 'default')
|
|
74
|
+
return opened.values.resolve(own.value, [program]);
|
|
75
|
+
if (own?.kind === 'function')
|
|
76
|
+
return UNRESOLVED;
|
|
77
|
+
if (own?.kind === 'specifier' && own.source !== null) {
|
|
78
|
+
const from = module.readModule(own.source);
|
|
79
|
+
return 'reason' in from ? UNRESOLVED : (this.exported(from, own.local) ?? UNRESOLVED);
|
|
80
|
+
}
|
|
81
|
+
if (own) {
|
|
82
|
+
return opened.values.resolveName(own.kind === 'specifier' ? own.local : name, [program]);
|
|
83
|
+
}
|
|
84
|
+
// `export *` passes on every name but the default. When two modules export the name with different values, Node
|
|
85
|
+
// refuses to load the module and Vite's module runner keeps the first, so the value isn't known.
|
|
86
|
+
if (name === 'default')
|
|
87
|
+
return null;
|
|
88
|
+
let found = null;
|
|
89
|
+
for (const statement of program.body) {
|
|
90
|
+
const source = starReexport(statement);
|
|
91
|
+
const from = source === null ? null : module.readModule(source);
|
|
92
|
+
const result = from && !('reason' in from) ? this.exported(from, name) : null;
|
|
93
|
+
if (!result)
|
|
94
|
+
continue;
|
|
95
|
+
if (found && !(found.ok && result.ok && JSON.stringify(found.value) === JSON.stringify(result.value))) {
|
|
96
|
+
return UNRESOLVED;
|
|
97
|
+
}
|
|
98
|
+
found = result;
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
open(module) {
|
|
103
|
+
if (this.modules.has(module))
|
|
104
|
+
return this.modules.get(module);
|
|
105
|
+
let opened = null;
|
|
106
|
+
if (module.relativeFilePath.endsWith('.json')) {
|
|
107
|
+
const value = parseJson(module.code);
|
|
108
|
+
opened = value === undefined ? null : { program: null, json: { value }, values: new Values() };
|
|
109
|
+
}
|
|
110
|
+
else if (!module.bytes) {
|
|
111
|
+
const parsed = parseModule(module);
|
|
112
|
+
const values = new Values(this.reader(module.readModule));
|
|
113
|
+
opened = parsed.error ? null : { program: parsed.program, json: null, values };
|
|
114
|
+
}
|
|
115
|
+
this.modules.set(module, opened);
|
|
116
|
+
return opened;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function parseJson(code) {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(code);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** A JSON module's default export is its value; Vite and Playwright also export its top-level keys by name. */
|
|
128
|
+
function jsonExport(value, name) {
|
|
129
|
+
if (name === 'default')
|
|
130
|
+
return { ok: true, value };
|
|
131
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.hasOwn(value, name)) {
|
|
132
|
+
return { ok: true, value: value[name] };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
package/dist/model.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CallExpression, Expression, Node } from 'oxc-parser';
|
|
2
2
|
import type { FunctionNode } from './ast.ts';
|
|
3
3
|
import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
|
|
4
|
+
import type { Value } from './values.ts';
|
|
4
5
|
export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
|
|
5
6
|
/** The Vitest options that set a test's state. */
|
|
6
7
|
export type StateOption = 'skip' | 'only' | 'todo' | 'fails';
|
|
@@ -82,8 +83,15 @@ interface Declaration {
|
|
|
82
83
|
isParameterized: boolean;
|
|
83
84
|
/** Number of cases of `.each` or `.for` when the table is written out; null otherwise. */
|
|
84
85
|
caseCount: number | null;
|
|
86
|
+
/** The cases of `.each` or `.for` when the scan can work out every value in them; null otherwise. */
|
|
87
|
+
cases: TableCase[] | null;
|
|
85
88
|
problems: Problem[];
|
|
86
89
|
}
|
|
90
|
+
/** One case of a `.each` or `.for` table: the items its title is formatted with, and the arguments its callback gets. */
|
|
91
|
+
export interface TableCase {
|
|
92
|
+
items: Value[];
|
|
93
|
+
args: Value[];
|
|
94
|
+
}
|
|
87
95
|
export interface TestDeclaration extends Declaration {
|
|
88
96
|
kind: 'test';
|
|
89
97
|
title: Title;
|
package/dist/options.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface ScanOptions {
|
|
|
17
17
|
frameworkVersion?: string;
|
|
18
18
|
/** Which tests get the comments above them. Defaults to `non-active`. */
|
|
19
19
|
comments?: CommentsMode;
|
|
20
|
+
/**
|
|
21
|
+
* One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
|
|
22
|
+
* out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
|
|
23
|
+
*/
|
|
24
|
+
expandCases?: boolean;
|
|
20
25
|
}
|
|
21
26
|
/** @inline */
|
|
22
27
|
export interface ScanSourceOptions {
|
|
@@ -28,6 +33,11 @@ export interface ScanSourceOptions {
|
|
|
28
33
|
frameworkVersion?: string | null;
|
|
29
34
|
/** Which tests get the comments above them. Defaults to `non-active`. */
|
|
30
35
|
comments?: CommentsMode;
|
|
36
|
+
/**
|
|
37
|
+
* One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
|
|
38
|
+
* out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
|
|
39
|
+
*/
|
|
40
|
+
expandCases?: boolean;
|
|
31
41
|
}
|
|
32
42
|
/** An invalid option, told apart from other errors so that the command line can show it as a usage error. */
|
|
33
43
|
export declare class OptionsError extends TypeError {
|
|
@@ -36,6 +46,7 @@ export declare function invalid(option: string, expected: string): OptionsError;
|
|
|
36
46
|
export declare function checkObject(value: unknown, name: string): Record<string, unknown>;
|
|
37
47
|
export declare function checkFramework(value: unknown, option: string): Framework;
|
|
38
48
|
export declare function checkComments(value: unknown, option: string): CommentsMode;
|
|
49
|
+
export declare function checkFlag(value: unknown, option: string): boolean;
|
|
39
50
|
export declare function checkVersion(value: unknown, option: string): string | null;
|
|
40
51
|
export declare function checkString(value: unknown, option: string): string;
|
|
41
52
|
export declare function checkStrings(value: unknown, option: string, required: boolean): string[];
|
package/dist/options.js
CHANGED
|
@@ -25,6 +25,13 @@ export function checkComments(value, option) {
|
|
|
25
25
|
throw invalid(option, '"all", "non-active" or "none"');
|
|
26
26
|
return value;
|
|
27
27
|
}
|
|
28
|
+
export function checkFlag(value, option) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
if (typeof value !== 'boolean')
|
|
32
|
+
throw invalid(option, 'true or false');
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
28
35
|
export function checkVersion(value, option) {
|
|
29
36
|
if (value === undefined || value === null)
|
|
30
37
|
return null;
|
package/dist/records.js
CHANGED
|
@@ -305,6 +305,7 @@ class RecordBuilder {
|
|
|
305
305
|
isSerial: scope.mode === 'serial',
|
|
306
306
|
isShuffled: declaration.isShuffled,
|
|
307
307
|
isParameterized: declaration.isParameterized,
|
|
308
|
+
caseIndex: pending.caseIndex,
|
|
308
309
|
tags: unique(scope.details.tags),
|
|
309
310
|
annotations: scope.details.annotations,
|
|
310
311
|
locks: unique(scope.details.locks),
|
|
@@ -389,8 +390,9 @@ class RecordBuilder {
|
|
|
389
390
|
}
|
|
390
391
|
if (declaration.isOnly)
|
|
391
392
|
this.forbidOnly(located, pending.site ? null : pending.call);
|
|
392
|
-
|
|
393
|
-
|
|
393
|
+
// A loop the scan counts is described by caseCount, as a table is.
|
|
394
|
+
if (pending.loops.includes(null)) {
|
|
395
|
+
this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, "The test is declared in a loop the scan can't count; it is listed once, while the runner creates one test per pass."), id));
|
|
394
396
|
}
|
|
395
397
|
if ((declaration.isOnly || fromTags?.only === true) &&
|
|
396
398
|
chosen &&
|
|
@@ -455,7 +457,10 @@ class RecordBuilder {
|
|
|
455
457
|
steps: pending.steps,
|
|
456
458
|
isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
|
|
457
459
|
isParameterized: tables.length > 0,
|
|
458
|
-
caseCount: tables.length > 0
|
|
460
|
+
caseCount: tables.length > 0 || pending.isInLoop
|
|
461
|
+
? repetitions([...tables.map((item) => item.caseCount), ...pending.loops])
|
|
462
|
+
: null,
|
|
463
|
+
caseIndex: pending.caseIndex,
|
|
459
464
|
isInLoop: pending.isInLoop,
|
|
460
465
|
isInCondition: pending.isInCondition,
|
|
461
466
|
isInFunction: pending.isInFunction,
|
|
@@ -482,7 +487,9 @@ class RecordBuilder {
|
|
|
482
487
|
const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
|
|
483
488
|
const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
|
|
484
489
|
// In a loop, a title path that never changes is registered again on each pass.
|
|
485
|
-
const repeated = playwright &&
|
|
490
|
+
const repeated = playwright &&
|
|
491
|
+
test.passes.some((passes) => passes === null || passes > 1) &&
|
|
492
|
+
describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
|
|
486
493
|
if (repeated) {
|
|
487
494
|
const message = 'The test is declared in a loop with the same title on each pass, so Playwright fails to load the file and runs no tests at all.';
|
|
488
495
|
const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
|
package/dist/scan-file.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RunsWith } from './config-dependencies.ts';
|
|
2
2
|
import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
|
|
3
|
+
import { ExportedValues } from './imported-values.ts';
|
|
3
4
|
import type { ModuleReader } from './model.ts';
|
|
4
5
|
import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
|
|
5
6
|
import type { Framework } from './types.ts';
|
|
@@ -8,6 +9,8 @@ export interface FileSettings {
|
|
|
8
9
|
/** `[major, minor]` of the framework version; null applies the newest rules. */
|
|
9
10
|
version: [number, number] | null;
|
|
10
11
|
comments: CommentsMode;
|
|
12
|
+
/** One test or describe per case of a table and per pass of a loop whose values the scan can work out. */
|
|
13
|
+
expandCases?: boolean;
|
|
11
14
|
readModule: ModuleReader | null;
|
|
12
15
|
/** The project root, left out of error messages so that the output has no absolute paths. */
|
|
13
16
|
root: string | null;
|
|
@@ -17,6 +20,8 @@ export interface FileSettings {
|
|
|
17
20
|
config?: FileConfig | null;
|
|
18
21
|
/** Shared by the files of one scan, so that code many tests reach is read once. */
|
|
19
22
|
graph?: DependencyGraph;
|
|
23
|
+
/** Shared by the files of one scan, so that each module's exported values are worked out once. */
|
|
24
|
+
exports?: ExportedValues;
|
|
20
25
|
/** What the file's config runs with each of its tests: the config, setup files and setup projects. */
|
|
21
26
|
runsWith?: RunsWith;
|
|
22
27
|
/** Reads the snapshot files tests compare against; none when one file's code is scanned. */
|
package/dist/scan-file.js
CHANGED
|
@@ -2,6 +2,7 @@ import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
|
|
|
2
2
|
import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
|
|
3
3
|
import { extract, noHooks } from "./extract.js";
|
|
4
4
|
import { parseModule } from "./frameworks/bindings.js";
|
|
5
|
+
import { ExportedValues } from "./imported-values.js";
|
|
5
6
|
import { atLeast, problem } from "./frameworks/common.js";
|
|
6
7
|
import { PlaywrightAdapter } from "./frameworks/playwright.js";
|
|
7
8
|
import { VitestAdapter } from "./frameworks/vitest.js";
|
|
@@ -12,8 +13,9 @@ import { Values } from "./values.js";
|
|
|
12
13
|
const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
|
|
13
14
|
const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
|
|
14
15
|
/** A file read for the scan: the scanned file, or a module a helper function is imported from. */
|
|
15
|
-
function frame(parsed, relativeFilePath, settings, readModule) {
|
|
16
|
-
const
|
|
16
|
+
function frame(parsed, relativeFilePath, settings, readModule, exports) {
|
|
17
|
+
const values = new Values(exports.reader(readModule));
|
|
18
|
+
const file = { source: parsed.source, values, version: settings.version };
|
|
17
19
|
const adapter = settings.framework === 'playwright'
|
|
18
20
|
? new PlaywrightAdapter(parsed.program, file, readModule)
|
|
19
21
|
: new VitestAdapter(parsed, file, readModule);
|
|
@@ -35,11 +37,12 @@ export function scanFile(code, relativeFilePath, settings) {
|
|
|
35
37
|
if (parsed.program.body.length === 0)
|
|
36
38
|
return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
|
|
37
39
|
}
|
|
38
|
-
const
|
|
40
|
+
const exports = settings.exports ?? new ExportedValues();
|
|
41
|
+
const main = frame(parsed, relativeFilePath, settings, settings.readModule, exports);
|
|
39
42
|
const extraction = extract(main, (module) => {
|
|
40
43
|
const helper = parseModule(module);
|
|
41
|
-
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
|
|
42
|
-
});
|
|
44
|
+
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule, exports);
|
|
45
|
+
}, { expandCases: settings.expandCases ?? false, version: settings.version });
|
|
43
46
|
const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
|
|
44
47
|
const unfollowed = [];
|
|
45
48
|
const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
|
package/dist/scan-source.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInventory } from "./inventory.js";
|
|
2
|
-
import { checkComments, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
2
|
+
import { checkComments, checkFlag, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
3
3
|
import { scanFile } from "./scan-file.js";
|
|
4
4
|
/**
|
|
5
5
|
* Scans the code of one test file, without reading anything from disk.
|
|
@@ -15,10 +15,12 @@ export function scanSource(options) {
|
|
|
15
15
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
16
16
|
const frameworkVersion = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
|
|
17
17
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
18
|
+
const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
|
|
18
19
|
const result = scanFile(checked.code, relativeFilePath, {
|
|
19
20
|
framework,
|
|
20
21
|
version: majorMinor(frameworkVersion),
|
|
21
22
|
comments,
|
|
23
|
+
expandCases,
|
|
22
24
|
readModule: null,
|
|
23
25
|
root: null,
|
|
24
26
|
});
|
package/dist/scan.js
CHANGED
|
@@ -6,11 +6,12 @@ import { ConfigDependencies } from "./config-dependencies.js";
|
|
|
6
6
|
import { ConfigRuns, fileConfig } from "./config-runs.js";
|
|
7
7
|
import { Configs } from "./config.js";
|
|
8
8
|
import { DependencyGraph } from "./dependency-hash.js";
|
|
9
|
+
import { ExportedValues } from "./imported-values.js";
|
|
9
10
|
import { fileDiagnostic } from "./diagnostics.js";
|
|
10
11
|
import { findFiles, normalizePattern, readFailure } from "./files.js";
|
|
11
12
|
import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
|
|
12
13
|
import { createInventory } from "./inventory.js";
|
|
13
|
-
import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
14
|
+
import { checkComments, checkFlag, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
14
15
|
import { moduleResolver } from "./resolve.js";
|
|
15
16
|
import { noRecords, scanFile } from "./scan-file.js";
|
|
16
17
|
import { globalsCheck } from "./vitest-config.js";
|
|
@@ -114,6 +115,7 @@ export async function scan(options) {
|
|
|
114
115
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
115
116
|
const ignore = checkStrings(checked.ignore, 'options.ignore', false);
|
|
116
117
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
118
|
+
const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
|
|
117
119
|
const override = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
|
|
118
120
|
if (checked.root !== undefined && (typeof checked.root !== 'string' || checked.root === '')) {
|
|
119
121
|
throw invalid('options.root', 'a non-empty string');
|
|
@@ -121,7 +123,10 @@ export async function scan(options) {
|
|
|
121
123
|
const root = path.resolve(checked.root ?? process.cwd());
|
|
122
124
|
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
123
125
|
throw invalid('options.root', `a directory, and ${root} is not one`);
|
|
124
|
-
|
|
126
|
+
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
127
|
+
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
128
|
+
const scanned = found.files.map((file) => file.relativeFilePath);
|
|
129
|
+
const detected = override === null ? detectFrameworkVersion(root, framework, scanned) : null;
|
|
125
130
|
const frameworkVersion = override ?? detected?.version ?? null;
|
|
126
131
|
const range = detected?.range ?? null;
|
|
127
132
|
// With only a range, the newest rules of its major apply, as the newest release in it is the likeliest installed.
|
|
@@ -130,11 +135,10 @@ export async function scan(options) {
|
|
|
130
135
|
: majorMinor(frameworkVersion);
|
|
131
136
|
const source = override === null ? (detected?.source ?? 'package.json') : '.';
|
|
132
137
|
const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
|
|
133
|
-
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
134
|
-
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
135
138
|
projectDiagnostics.push(...found.diagnostics);
|
|
136
139
|
const readerFor = moduleResolver(root);
|
|
137
140
|
const graph = new DependencyGraph();
|
|
141
|
+
const exports = new ExportedValues();
|
|
138
142
|
const configs = new Configs(root, framework, readerFor, rules);
|
|
139
143
|
const runs = new ConfigRuns(configs, framework, rules, patterns === null);
|
|
140
144
|
const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
|
|
@@ -193,11 +197,13 @@ export async function scan(options) {
|
|
|
193
197
|
framework,
|
|
194
198
|
version: rules,
|
|
195
199
|
comments,
|
|
200
|
+
expandCases,
|
|
196
201
|
readModule: readerFor(absolutePath),
|
|
197
202
|
root,
|
|
198
203
|
isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
|
|
199
204
|
config: fileConfig(placement, framework, rules, absolutePath),
|
|
200
205
|
graph,
|
|
206
|
+
exports,
|
|
201
207
|
runsWith,
|
|
202
208
|
snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
|
|
203
209
|
});
|