test-inventory 0.2.1 → 0.3.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 +18 -0
- package/README.md +3 -1
- package/dist/ast.d.ts +11 -0
- package/dist/ast.js +30 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +59 -12
- package/dist/extract.js +26 -41
- package/dist/filter.d.ts +37 -0
- package/dist/filter.js +153 -0
- package/dist/frameworks/bindings.d.ts +37 -6
- package/dist/frameworks/bindings.js +167 -33
- package/dist/frameworks/common.d.ts +6 -1
- package/dist/frameworks/common.js +25 -3
- package/dist/frameworks/playwright.d.ts +2 -1
- package/dist/frameworks/playwright.js +58 -26
- package/dist/frameworks/vitest.d.ts +1 -1
- package/dist/frameworks/vitest.js +110 -67
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +7 -1
- package/dist/json.js +30 -9
- package/dist/model.d.ts +23 -3
- package/dist/records.js +5 -3
- package/dist/types.d.ts +16 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# test-inventory
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - `--fields` keeps only the named keys of each test, and `id`, as in `--fields fullName,reason`, so a short question gets a short answer. Output written with it leaves keys out, so it no longer matches the JSON Schema. The library exports the same as `pickTestFields()`.
|
|
8
|
+
|
|
9
|
+
- [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - Filters keep only the tests a question is about: `--state`, `--tag`, `--grep` (a regular expression on the full name, ignoring case), `--only` and `--without-reason`, as in `--state skip --state fixme` for the tests that don't run. The output then describes only those tests: the describes around them, their diagnostics and a summary counted over them. The library exports the same as `filterInventory()`.
|
|
10
|
+
|
|
11
|
+
- [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - `--format jsonl` writes the inventory as JSON Lines: an `inventory` line, one line per suite, test and diagnostic, and a `summary` line, each marked with a `type` key. Tools that read one record per line, such as `grep` or DuckDB, no longer need the whole document in memory. The `InventoryLine` type describes the lines.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - A fixtures file that builds its test object with a function, written there or imported from another file, as in `export const test = defineDeskTest('equities')`, is followed: MSW's test files import such a `test`. Their tests were missing before, with no diagnostic when the fixtures file didn't import the framework itself. A test object built with a call the scan can't follow now gets an `unresolved-test-import` error that names the call, as in "builds it with withWarmup(), which is not followed". A function that returns what another function returns, as `() => onFeed('NYSE')`, is followed too.
|
|
16
|
+
|
|
17
|
+
- [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - A call the runner refuses that is written like a declaration, as `describe.sequential('title', fn)` in Vitest 5, `test('title', fn, { retry: 2 })` from Vitest 4 or `test.only.skip('title', fn)`, is listed as a plain test or describe without the part that fails. Its tests were missing before; now they are `notLoaded`, like every other test in the file.
|
|
18
|
+
|
|
19
|
+
- [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - States follow the runners' precedence more closely, as checked against Vitest 5.0.4 running generated files. A skip in a test or hook body loses to a fixme, skip or todo set while the file loads, as on a describe around the test, since the body never runs. In Vitest, a test's own options win over its chain, so `test.skip('title', { skip: false }, fn)` is active, but the chain still sets its mode: `test.concurrent('title', { concurrent: false }, fn)` runs in parallel. A describe passes its `skip` option down to the tests and describes inside, where it wins over todo, also when the describe's own `.only` keeps the describe itself from being skipped.
|
|
20
|
+
|
|
3
21
|
## 0.2.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -70,7 +70,9 @@ the first test's record includes:
|
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
The output also has a `summary` with counts, a record for each describe, and `diagnostics` that point at code the
|
|
73
|
-
scan could not read and values it could not work out.
|
|
73
|
+
scan could not read and values it could not work out. Filters such as `--state skip`, `--tag @smoke` and
|
|
74
|
+
`--grep "limit order"` keep only some tests, `--fields fullName,reason` keeps only some keys, and `--format jsonl`
|
|
75
|
+
writes one record per line.
|
|
74
76
|
|
|
75
77
|
## Library
|
|
76
78
|
|
package/dist/ast.d.ts
CHANGED
|
@@ -4,6 +4,17 @@ export declare function childNodes(node: Node): Node[];
|
|
|
4
4
|
/** Strips TypeScript-only wrappers such as `test!`, `test as any` and `<any>test`. */
|
|
5
5
|
export declare function unwrap(node: Expression): Expression;
|
|
6
6
|
export declare function isFunctionNode(node: Node | null | undefined): node is FunctionNode;
|
|
7
|
+
/** A value a function returns, with the nodes inside the function around its `return`, outermost first. */
|
|
8
|
+
export interface ReturnValue {
|
|
9
|
+
expression: Expression;
|
|
10
|
+
around: Node[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The values a function returns; null when it may return no value, as with `return;` or no `return` at all, and for
|
|
14
|
+
* an async function or a generator, which returns a promise or an iterator whatever its `return` says. A `return` in a
|
|
15
|
+
* function inside it is that function's.
|
|
16
|
+
*/
|
|
17
|
+
export declare function returnValues(fn: FunctionNode): ReturnValue[] | null;
|
|
7
18
|
export declare function isStringLiteral(node: Node): node is StringLiteral;
|
|
8
19
|
/** `import.meta.vitest`, which in-source Vitest tests read their functions from. */
|
|
9
20
|
export declare function isImportMetaVitest(expression: Expression): boolean;
|
package/dist/ast.js
CHANGED
|
@@ -32,6 +32,36 @@ export function unwrap(node) {
|
|
|
32
32
|
export function isFunctionNode(node) {
|
|
33
33
|
return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression';
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The values a function returns; null when it may return no value, as with `return;` or no `return` at all, and for
|
|
37
|
+
* an async function or a generator, which returns a promise or an iterator whatever its `return` says. A `return` in a
|
|
38
|
+
* function inside it is that function's.
|
|
39
|
+
*/
|
|
40
|
+
export function returnValues(fn) {
|
|
41
|
+
if (fn.async || fn.generator)
|
|
42
|
+
return null;
|
|
43
|
+
// Only declarations of overloads have no body.
|
|
44
|
+
const body = fn.body;
|
|
45
|
+
if (body.type !== 'BlockStatement')
|
|
46
|
+
return [{ expression: body, around: [] }];
|
|
47
|
+
const values = [];
|
|
48
|
+
// Copied only at a `return`, so that a long function costs no more than its size.
|
|
49
|
+
const around = [];
|
|
50
|
+
const visit = (node) => {
|
|
51
|
+
if (node.type === 'ReturnStatement') {
|
|
52
|
+
values.push(node.argument ? { expression: node.argument, around: [...around] } : null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
56
|
+
return;
|
|
57
|
+
around.push(node);
|
|
58
|
+
for (const child of childNodes(node))
|
|
59
|
+
visit(child);
|
|
60
|
+
around.pop();
|
|
61
|
+
};
|
|
62
|
+
visit(body);
|
|
63
|
+
return values.length > 0 && !values.includes(null) ? values : null;
|
|
64
|
+
}
|
|
35
65
|
export function isStringLiteral(node) {
|
|
36
66
|
return node.type === 'Literal' && typeof node.value === 'string';
|
|
37
67
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -4,6 +4,6 @@ export interface Io {
|
|
|
4
4
|
stderr: Writable;
|
|
5
5
|
cwd: string;
|
|
6
6
|
}
|
|
7
|
-
export declare const HELP = "Usage: test-inventory <glob...> --framework <playwright|vitest> [options]\n\nLists the tests in the matching files as JSON, read from the source code without running them.\n\nOptions:\n --framework <name> playwright or vitest (required)\n --root <dir> project root; paths in the output are relative to it (default: current directory)\n --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)\n --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project\n --comments <mode> comments above tests: all, non-active or none (default: non-active)\n -o, --output <file> write to a file instead of standard output\n --pretty indent the JSON\n -h, --help show this help\n -v, --version show the version\n\nExit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be\nwritten, or a crash. 1 is reserved for a future release.\n\
|
|
7
|
+
export declare const HELP = "Usage: test-inventory <glob...> --framework <playwright|vitest> [options]\n\nLists the tests in the matching files as JSON, read from the source code without running them.\n\nOptions:\n --framework <name> playwright or vitest (required)\n --root <dir> project root; paths in the output are relative to it (default: current directory)\n --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)\n --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project\n --comments <mode> comments above tests: all, non-active or none (default: non-active)\n --state <state> keep tests in this state, such as skip or fixme; can be repeated\n --tag <tag> keep tests with this tag, such as @smoke; can be repeated\n --grep <regex> keep tests whose full name matches, ignoring case\n --only keep tests that use .only, or are inside a describe that does\n --without-reason keep tests that don't run and give no reason\n --fields <keys> write only these keys of each test, separated by commas; id is always kept\n --format <format> json or jsonl, one record per line (default: json)\n -o, --output <file> write to a file instead of standard output\n --pretty indent the JSON\n -h, --help show this help\n -v, --version show the version\n\nExit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be\nwritten, or a crash. 1 is reserved for a future release.\n\nExamples:\n test-inventory \"tests/**/*.spec.ts\" --framework playwright --output inventory.json\n test-inventory \"tests/**/*.spec.ts\" --framework playwright --state skip --fields fullName,reason\n";
|
|
8
8
|
/** Runs the command line with the given arguments and streams. Resolves to the exit code. */
|
|
9
9
|
export declare function run(argv: string[], io: Io): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,8 @@ import { once } from 'node:events';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { readFailure } from "./files.js";
|
|
6
|
-
import {
|
|
6
|
+
import { checkFields, filterInventory, matcher, pickTestFields } from "./filter.js";
|
|
7
|
+
import { writeInventory, writeInventoryLines } from "./json.js";
|
|
7
8
|
import { OptionsError } from "./options.js";
|
|
8
9
|
import { scan } from "./scan.js";
|
|
9
10
|
import { toolVersion } from "./version.js";
|
|
@@ -17,6 +18,13 @@ Options:
|
|
|
17
18
|
--ignore <glob> leave out matching files; can be repeated (node_modules is always left out)
|
|
18
19
|
--framework-version <x.y.z> apply the rules of this framework version instead of the one in the project
|
|
19
20
|
--comments <mode> comments above tests: all, non-active or none (default: non-active)
|
|
21
|
+
--state <state> keep tests in this state, such as skip or fixme; can be repeated
|
|
22
|
+
--tag <tag> keep tests with this tag, such as @smoke; can be repeated
|
|
23
|
+
--grep <regex> keep tests whose full name matches, ignoring case
|
|
24
|
+
--only keep tests that use .only, or are inside a describe that does
|
|
25
|
+
--without-reason keep tests that don't run and give no reason
|
|
26
|
+
--fields <keys> write only these keys of each test, separated by commas; id is always kept
|
|
27
|
+
--format <format> json or jsonl, one record per line (default: json)
|
|
20
28
|
-o, --output <file> write to a file instead of standard output
|
|
21
29
|
--pretty indent the JSON
|
|
22
30
|
-h, --help show this help
|
|
@@ -25,18 +33,24 @@ Options:
|
|
|
25
33
|
Exit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be
|
|
26
34
|
written, or a crash. 1 is reserved for a future release.
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
Examples:
|
|
29
37
|
test-inventory "tests/**/*.spec.ts" --framework playwright --output inventory.json
|
|
38
|
+
test-inventory "tests/**/*.spec.ts" --framework playwright --state skip --fields fullName,reason
|
|
30
39
|
`;
|
|
31
|
-
/** CLI names for the option names in
|
|
40
|
+
/** CLI names for the option names in the library's errors. */
|
|
32
41
|
const FLAGS = {
|
|
33
|
-
patterns: 'the glob patterns',
|
|
34
|
-
framework: '--framework',
|
|
35
|
-
root: '--root',
|
|
36
|
-
ignore: '--ignore',
|
|
37
|
-
frameworkVersion: '--framework-version',
|
|
38
|
-
comments: '--comments',
|
|
42
|
+
'options.patterns': 'the glob patterns',
|
|
43
|
+
'options.framework': '--framework',
|
|
44
|
+
'options.root': '--root',
|
|
45
|
+
'options.ignore': '--ignore',
|
|
46
|
+
'options.frameworkVersion': '--framework-version',
|
|
47
|
+
'options.comments': '--comments',
|
|
48
|
+
'filter.states': '--state',
|
|
49
|
+
'filter.tags': '--tag',
|
|
50
|
+
'filter.grep': '--grep',
|
|
51
|
+
fields: '--fields',
|
|
39
52
|
};
|
|
53
|
+
const FORMATS = ['json', 'jsonl'];
|
|
40
54
|
async function writeTo(stream, chunk) {
|
|
41
55
|
if (stream.errored)
|
|
42
56
|
throw stream.errored;
|
|
@@ -89,6 +103,13 @@ export async function run(argv, io) {
|
|
|
89
103
|
ignore: { type: 'string', multiple: true },
|
|
90
104
|
'framework-version': { type: 'string' },
|
|
91
105
|
comments: { type: 'string' },
|
|
106
|
+
state: { type: 'string', multiple: true },
|
|
107
|
+
tag: { type: 'string', multiple: true },
|
|
108
|
+
grep: { type: 'string' },
|
|
109
|
+
only: { type: 'boolean' },
|
|
110
|
+
'without-reason': { type: 'boolean' },
|
|
111
|
+
fields: { type: 'string' },
|
|
112
|
+
format: { type: 'string' },
|
|
92
113
|
output: { type: 'string', short: 'o' },
|
|
93
114
|
pretty: { type: 'boolean' },
|
|
94
115
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -112,8 +133,26 @@ export async function run(argv, io) {
|
|
|
112
133
|
return fail('give at least one glob pattern for the test files.');
|
|
113
134
|
if (values.framework === undefined)
|
|
114
135
|
return fail('--framework is required: playwright or vitest.');
|
|
136
|
+
const format = values.format ?? 'json';
|
|
137
|
+
if (!FORMATS.includes(format))
|
|
138
|
+
return fail('--format must be json or jsonl.');
|
|
139
|
+
if (format === 'jsonl' && values.pretty)
|
|
140
|
+
return fail('--pretty indents JSON, so it does not go with --format jsonl.');
|
|
141
|
+
const filter = {
|
|
142
|
+
states: values.state,
|
|
143
|
+
tags: values.tag,
|
|
144
|
+
grep: values.grep,
|
|
145
|
+
only: values.only,
|
|
146
|
+
withoutReason: values['without-reason'],
|
|
147
|
+
};
|
|
148
|
+
const fields = values.fields?.split(',').map((field) => field.trim());
|
|
115
149
|
let inventory;
|
|
150
|
+
let result;
|
|
116
151
|
try {
|
|
152
|
+
// Checked before the scan, so a mistyped state or key doesn't wait for every file to be read.
|
|
153
|
+
matcher(filter);
|
|
154
|
+
if (fields !== undefined)
|
|
155
|
+
checkFields(fields);
|
|
117
156
|
inventory = await scan({
|
|
118
157
|
patterns: positionals,
|
|
119
158
|
framework: values.framework,
|
|
@@ -122,10 +161,12 @@ export async function run(argv, io) {
|
|
|
122
161
|
frameworkVersion: values['framework-version'],
|
|
123
162
|
comments: values.comments,
|
|
124
163
|
});
|
|
164
|
+
const filtered = filterInventory(inventory, filter);
|
|
165
|
+
result = fields === undefined ? filtered : pickTestFields(filtered, fields);
|
|
125
166
|
}
|
|
126
167
|
catch (error) {
|
|
127
168
|
if (error instanceof OptionsError) {
|
|
128
|
-
return fail(error.message.replace(/^
|
|
169
|
+
return fail(error.message.replace(/^[\w.]+/, (name) => FLAGS[name]));
|
|
129
170
|
}
|
|
130
171
|
io.stderr.write(`test-inventory: unexpected error: ${error instanceof Error ? String(error.stack) : String(error)}\n`);
|
|
131
172
|
return FAILED;
|
|
@@ -139,8 +180,14 @@ export async function run(argv, io) {
|
|
|
139
180
|
io.stdout.on('error', quiet);
|
|
140
181
|
try {
|
|
141
182
|
await writeOutput(io, values.output, async (output) => {
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
const write = (chunk) => writeTo(output, chunk);
|
|
184
|
+
if (format === 'jsonl') {
|
|
185
|
+
await writeInventoryLines(result, write);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
await writeInventory(result, values.pretty === true, write);
|
|
189
|
+
await write('\n');
|
|
190
|
+
}
|
|
144
191
|
});
|
|
145
192
|
}
|
|
146
193
|
catch (error) {
|
package/dist/extract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
|
|
1
|
+
import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
|
|
2
2
|
import { exportName } from "./frameworks/bindings.js";
|
|
3
3
|
import { problem } from "./frameworks/common.js";
|
|
4
4
|
import { snippet, splitLines } from "./source.js";
|
|
@@ -294,6 +294,7 @@ class Walker {
|
|
|
294
294
|
testContext: context.testContext,
|
|
295
295
|
inTest: context.test !== null,
|
|
296
296
|
inBody: context.test !== null || context.hookScope !== null,
|
|
297
|
+
passedDown: context.scope.suite?.declaration.passesDown ?? null,
|
|
297
298
|
returnsOf: (name, scopes) => this.returnsOf(name, scopes),
|
|
298
299
|
});
|
|
299
300
|
if (!api)
|
|
@@ -323,6 +324,7 @@ class Walker {
|
|
|
323
324
|
reason: api.reason,
|
|
324
325
|
hasDynamicReason: api.hasDynamicReason,
|
|
325
326
|
line: this.lineOf(call),
|
|
327
|
+
afterLoad: context.test !== null || context.hookScope !== null,
|
|
326
328
|
};
|
|
327
329
|
if (context.helper)
|
|
328
330
|
this.effectsOf(context.helper).push({ kind: 'state', setting, call });
|
|
@@ -360,7 +362,7 @@ class Walker {
|
|
|
360
362
|
case 'invalid': {
|
|
361
363
|
const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
|
|
362
364
|
this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
|
|
363
|
-
return true;
|
|
365
|
+
return api.declaration ? this.handle(call, api.declaration, context) : true;
|
|
364
366
|
}
|
|
365
367
|
case 'other':
|
|
366
368
|
return false;
|
|
@@ -430,33 +432,40 @@ class Walker {
|
|
|
430
432
|
return null;
|
|
431
433
|
}
|
|
432
434
|
/**
|
|
433
|
-
* What a function declared in
|
|
434
|
-
* declaration is looked up once, however many calls use it.
|
|
435
|
+
* What a function declared in a file, the one being walked unless said otherwise, or imported from a local module,
|
|
436
|
+
* returns; null otherwise. Each declaration is looked up once, however many calls use it.
|
|
435
437
|
*/
|
|
436
|
-
returnsOf(name, scopes) {
|
|
437
|
-
const declaration =
|
|
438
|
+
returnsOf(name, scopes, frame = this.frame) {
|
|
439
|
+
const declaration = frame.values.declarationOf(name, scopes);
|
|
438
440
|
if (declaration === null)
|
|
439
441
|
return null;
|
|
440
442
|
if (!this.returns.has(declaration)) {
|
|
441
|
-
const local = this.
|
|
442
|
-
const callee = local ? { fn: local, frame
|
|
443
|
+
const local = this.functionIn(frame, name, scopes);
|
|
444
|
+
const callee = local ? { fn: local, frame } : this.importedFunction(name, scopes, frame);
|
|
443
445
|
this.returns.set(declaration, callee && this.returnsIn(callee));
|
|
444
446
|
}
|
|
445
447
|
return this.returns.get(declaration) ?? null;
|
|
446
448
|
}
|
|
447
449
|
returnsIn({ fn, frame }) {
|
|
448
|
-
|
|
449
|
-
const values =
|
|
450
|
-
|
|
450
|
+
const scopes = [...this.scopeChain(fn, frame), fn];
|
|
451
|
+
const values = returnValues(fn)?.map(({ expression, around }) => ({
|
|
452
|
+
expression,
|
|
453
|
+
scopes: [...scopes, ...around.filter(isScopeNode)],
|
|
454
|
+
}));
|
|
455
|
+
return {
|
|
456
|
+
adapter: frame.adapter,
|
|
457
|
+
values: values ?? null,
|
|
458
|
+
returnsOf: (name, at) => this.returnsOf(name, at, frame),
|
|
459
|
+
};
|
|
451
460
|
}
|
|
452
461
|
/** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
|
|
453
|
-
importedFunction(name, scopes) {
|
|
454
|
-
const declaration =
|
|
455
|
-
const source =
|
|
462
|
+
importedFunction(name, scopes, from = this.frame) {
|
|
463
|
+
const declaration = from.values.declarationOf(name, scopes);
|
|
464
|
+
const source = from.values.importSource(name, scopes);
|
|
456
465
|
if (source === null)
|
|
457
466
|
return null;
|
|
458
467
|
const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
|
|
459
|
-
const frame = this.openFrom(
|
|
468
|
+
const frame = this.openFrom(from, source);
|
|
460
469
|
return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
|
|
461
470
|
}
|
|
462
471
|
/** A module a file imports, read as a frame once; null when it can't be read or parsed. */
|
|
@@ -680,7 +689,8 @@ class Walker {
|
|
|
680
689
|
if (effect.kind === 'configure')
|
|
681
690
|
return [effect];
|
|
682
691
|
const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
|
|
683
|
-
|
|
692
|
+
// Helpers are expanded here only for calls from tests and hooks.
|
|
693
|
+
return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
|
|
684
694
|
});
|
|
685
695
|
};
|
|
686
696
|
for (const call of this.helperCalls) {
|
|
@@ -843,31 +853,6 @@ class Walker {
|
|
|
843
853
|
return lines;
|
|
844
854
|
}
|
|
845
855
|
}
|
|
846
|
-
/**
|
|
847
|
-
* The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
|
|
848
|
-
* with `return;` or no `return` at all. A `return` in a function inside it is that function's.
|
|
849
|
-
*/
|
|
850
|
-
function returnValues(fn, scopes) {
|
|
851
|
-
// Only declarations of overloads have no body.
|
|
852
|
-
const body = fn.body;
|
|
853
|
-
if (body.type !== 'BlockStatement')
|
|
854
|
-
return [{ expression: body, scopes }];
|
|
855
|
-
const values = [];
|
|
856
|
-
const visit = (node, around) => {
|
|
857
|
-
if (node.type === 'ReturnStatement') {
|
|
858
|
-
values.push(node.argument ? { expression: node.argument, scopes: around } : null);
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
862
|
-
return;
|
|
863
|
-
const inner = isScopeNode(node) ? [...around, node] : around;
|
|
864
|
-
for (const child of childNodes(node))
|
|
865
|
-
visit(child, inner);
|
|
866
|
-
};
|
|
867
|
-
visit(body, scopes);
|
|
868
|
-
const always = values.every((value) => value !== null);
|
|
869
|
-
return always && values.length > 0 ? values : null;
|
|
870
|
-
}
|
|
871
856
|
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
872
857
|
export function extract(main, openModule) {
|
|
873
858
|
return new Walker(main, openModule).run();
|
package/dist/filter.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Inventory, TestRecord, TestState } from './types.ts';
|
|
2
|
+
/** @inline */
|
|
3
|
+
export interface InventoryFilter {
|
|
4
|
+
/** Keeps tests in one of these states. */
|
|
5
|
+
states?: TestState[];
|
|
6
|
+
/** Keeps tests with one of these tags, as the output writes them. */
|
|
7
|
+
tags?: string[];
|
|
8
|
+
/** Keeps tests whose `fullName` matches this regular expression, ignoring case, as Playwright's `--grep` does. */
|
|
9
|
+
grep?: string;
|
|
10
|
+
/** Keeps tests that use `.only`, or are inside a describe that does. */
|
|
11
|
+
only?: boolean;
|
|
12
|
+
/** Keeps tests that don't run and give no reason. Tests in a file the runner refuses are left out. */
|
|
13
|
+
withoutReason?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** An inventory whose test records have only `id` and the keys picked with `pickTestFields()`. */
|
|
16
|
+
export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests'> & {
|
|
17
|
+
tests: Pick<TestRecord, 'id' | K>[];
|
|
18
|
+
};
|
|
19
|
+
/** Checks a filter and returns whether a test passes it. */
|
|
20
|
+
export declare function matcher(filter: InventoryFilter): (test: TestRecord) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
|
|
23
|
+
* result describes only those tests: the describes around them, their diagnostics, and a summary counted over them.
|
|
24
|
+
* `summary.fileCount` stays the number of files scanned.
|
|
25
|
+
*
|
|
26
|
+
* Throws a `TypeError` for an invalid filter.
|
|
27
|
+
*/
|
|
28
|
+
export declare function filterInventory(inventory: Inventory, filter: InventoryFilter): Inventory;
|
|
29
|
+
/** Checks the keys to pick and returns them after `id`, each once. */
|
|
30
|
+
export declare function checkFields(fields: unknown): (keyof TestRecord)[];
|
|
31
|
+
/**
|
|
32
|
+
* Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
|
|
33
|
+
* diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
|
|
34
|
+
*
|
|
35
|
+
* Throws a `TypeError` for a key that test records don't have.
|
|
36
|
+
*/
|
|
37
|
+
export declare function pickTestFields<K extends keyof TestRecord>(inventory: Inventory, fields: readonly K[]): PickedInventory<K>;
|
package/dist/filter.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { checkObject, OptionsError } from "./options.js";
|
|
2
|
+
import { summarize } from "./summary.js";
|
|
3
|
+
const STATES = ['active', 'skip', 'fixme', 'todo', 'expectedToFail', 'notLoaded'];
|
|
4
|
+
const FILTER_KEYS = [
|
|
5
|
+
'states',
|
|
6
|
+
'tags',
|
|
7
|
+
'grep',
|
|
8
|
+
'only',
|
|
9
|
+
'withoutReason',
|
|
10
|
+
];
|
|
11
|
+
// Typed as a record of every key, so a key added to TestRecord fails to compile until it is listed here.
|
|
12
|
+
const TEST_KEYS = Object.keys({
|
|
13
|
+
id: true,
|
|
14
|
+
bodyHash: true,
|
|
15
|
+
suiteId: true,
|
|
16
|
+
suitePath: true,
|
|
17
|
+
relativeFilePath: true,
|
|
18
|
+
fileName: true,
|
|
19
|
+
lineStart: true,
|
|
20
|
+
lineEnd: true,
|
|
21
|
+
columnStart: true,
|
|
22
|
+
columnEnd: true,
|
|
23
|
+
declaredAt: true,
|
|
24
|
+
title: true,
|
|
25
|
+
titleWithoutTags: true,
|
|
26
|
+
hasDynamicTitle: true,
|
|
27
|
+
fullName: true,
|
|
28
|
+
isTypeTest: true,
|
|
29
|
+
state: true,
|
|
30
|
+
isActive: true,
|
|
31
|
+
isSkipped: true,
|
|
32
|
+
isFixme: true,
|
|
33
|
+
isTodo: true,
|
|
34
|
+
isExpectedToFail: true,
|
|
35
|
+
isNotLoaded: true,
|
|
36
|
+
isOnly: true,
|
|
37
|
+
isConditional: true,
|
|
38
|
+
condition: true,
|
|
39
|
+
reason: true,
|
|
40
|
+
hasDynamicReason: true,
|
|
41
|
+
stateSource: true,
|
|
42
|
+
stateLine: true,
|
|
43
|
+
isParallel: true,
|
|
44
|
+
isSerial: true,
|
|
45
|
+
tags: true,
|
|
46
|
+
annotations: true,
|
|
47
|
+
comments: true,
|
|
48
|
+
locks: true,
|
|
49
|
+
meta: true,
|
|
50
|
+
isParameterized: true,
|
|
51
|
+
caseCount: true,
|
|
52
|
+
isInLoop: true,
|
|
53
|
+
isInCondition: true,
|
|
54
|
+
isInFunction: true,
|
|
55
|
+
});
|
|
56
|
+
function list(value, option, allowed) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return [];
|
|
59
|
+
const valid = (item) => typeof item === 'string' && item !== '' && (allowed === null || allowed.includes(item));
|
|
60
|
+
if (Array.isArray(value) && value.every(valid))
|
|
61
|
+
return value;
|
|
62
|
+
const bad = Array.isArray(value) ? value.find((item) => !valid(item)) : value;
|
|
63
|
+
const expected = allowed === null ? 'non-empty strings' : allowed.map((item) => `"${item}"`).join(', ');
|
|
64
|
+
throw new OptionsError(`${option} can only hold ${expected}; got ${JSON.stringify(bad)}.`);
|
|
65
|
+
}
|
|
66
|
+
function flag(value, option) {
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
if (typeof value !== 'boolean')
|
|
70
|
+
throw new OptionsError(`${option} must be true or false.`);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function pattern(value) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
if (typeof value !== 'string' || value === '')
|
|
77
|
+
throw new OptionsError('filter.grep must be a non-empty string.');
|
|
78
|
+
try {
|
|
79
|
+
return new RegExp(value, 'i');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new OptionsError(`filter.grep must be a regular expression: ${error.message}.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Checks a filter and returns whether a test passes it. */
|
|
86
|
+
export function matcher(filter) {
|
|
87
|
+
const checked = checkObject(filter, 'filter');
|
|
88
|
+
// A misspelled key would return every test, an answer that looks filtered but isn't.
|
|
89
|
+
const unknown = Object.keys(checked).find((key) => !FILTER_KEYS.includes(key));
|
|
90
|
+
if (unknown !== undefined) {
|
|
91
|
+
throw new OptionsError(`filter can only have the keys ${FILTER_KEYS.join(', ')}; got "${unknown}".`);
|
|
92
|
+
}
|
|
93
|
+
const states = list(checked.states, 'filter.states', STATES);
|
|
94
|
+
const tags = list(checked.tags, 'filter.tags', null);
|
|
95
|
+
const grep = pattern(checked.grep);
|
|
96
|
+
const only = flag(checked.only, 'filter.only');
|
|
97
|
+
const withoutReason = flag(checked.withoutReason, 'filter.withoutReason');
|
|
98
|
+
return (test) => (states.length === 0 || states.includes(test.state)) &&
|
|
99
|
+
(tags.length === 0 || test.tags.some((tag) => tags.includes(tag))) &&
|
|
100
|
+
(grep === null || grep.test(test.fullName)) &&
|
|
101
|
+
(!only || test.isOnly) &&
|
|
102
|
+
(!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
|
|
106
|
+
* result describes only those tests: the describes around them, their diagnostics, and a summary counted over them.
|
|
107
|
+
* `summary.fileCount` stays the number of files scanned.
|
|
108
|
+
*
|
|
109
|
+
* Throws a `TypeError` for an invalid filter.
|
|
110
|
+
*/
|
|
111
|
+
export function filterInventory(inventory, filter) {
|
|
112
|
+
const tests = inventory.tests.filter(matcher(filter));
|
|
113
|
+
const testIds = new Set(tests.map((test) => test.id));
|
|
114
|
+
const parents = new Map(inventory.suites.map((suite) => [suite.id, suite.parentId]));
|
|
115
|
+
const suiteIds = new Set();
|
|
116
|
+
for (const test of tests) {
|
|
117
|
+
for (let id = test.suiteId; id !== null && !suiteIds.has(id); id = parents.get(id)) {
|
|
118
|
+
suiteIds.add(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const suites = inventory.suites.filter((suite) => suiteIds.has(suite.id));
|
|
122
|
+
const filesWithTests = new Set(inventory.tests.map((test) => test.relativeFilePath));
|
|
123
|
+
const filesWithMatches = new Set(tests.map((test) => test.relativeFilePath));
|
|
124
|
+
// A problem that is not about one test stays, unless every test of its file was left out: an error in a file
|
|
125
|
+
// with no tests, or in package.json, may mean tests are missing from the answer.
|
|
126
|
+
const diagnostics = inventory.diagnostics.filter((diagnostic) => diagnostic.testId === null
|
|
127
|
+
? !filesWithTests.has(diagnostic.relativeFilePath) || filesWithMatches.has(diagnostic.relativeFilePath)
|
|
128
|
+
: testIds.has(diagnostic.testId));
|
|
129
|
+
const summary = summarize(inventory.summary.fileCount, suites, tests, diagnostics);
|
|
130
|
+
return { ...inventory, summary, suites, tests, diagnostics };
|
|
131
|
+
}
|
|
132
|
+
/** Checks the keys to pick and returns them after `id`, each once. */
|
|
133
|
+
export function checkFields(fields) {
|
|
134
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
135
|
+
throw new OptionsError('fields must be a non-empty array of test record keys.');
|
|
136
|
+
}
|
|
137
|
+
const bad = fields.findIndex((field) => !TEST_KEYS.includes(field));
|
|
138
|
+
if (bad !== -1) {
|
|
139
|
+
throw new OptionsError(`fields can only hold keys of a test record, such as "fullName"; got ${JSON.stringify(fields[bad])}.`);
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(['id', ...fields])];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
|
|
145
|
+
* diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
|
|
146
|
+
*
|
|
147
|
+
* Throws a `TypeError` for a key that test records don't have.
|
|
148
|
+
*/
|
|
149
|
+
export function pickTestFields(inventory, fields) {
|
|
150
|
+
const keys = checkFields(fields);
|
|
151
|
+
const tests = inventory.tests.map((test) => Object.fromEntries(keys.map((key) => [key, test[key]])));
|
|
152
|
+
return { ...inventory, tests };
|
|
153
|
+
}
|
|
@@ -9,12 +9,43 @@ export interface ModuleImport {
|
|
|
9
9
|
node: Node;
|
|
10
10
|
}
|
|
11
11
|
export declare function exportName(name: ModuleExportName): string;
|
|
12
|
+
/** What a module is followed for: a test object, or a function that returns one, as in `defineBrokerTest()`. */
|
|
13
|
+
export type Exported = 'test object' | 'function';
|
|
12
14
|
/** Names a module's test objects are known by, used to check what a fixtures file exports. */
|
|
13
15
|
export interface TestObjects {
|
|
14
16
|
isTestObject(expression: Expression): boolean;
|
|
15
17
|
isTestName(name: string): boolean;
|
|
16
|
-
/** Whether
|
|
17
|
-
|
|
18
|
+
/** Whether a function the module declares or imports under `name` returns a test object. */
|
|
19
|
+
returnsTestObject(name: string): boolean;
|
|
20
|
+
/** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
|
|
21
|
+
reexports(source: string, imported: string, exported: Exported): boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
|
|
25
|
+
* returns is one, as `return base.extend({...})` is. An imported function is followed to its module. The function a
|
|
26
|
+
* module exports as its default, named or not, is also known as `default`.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ModuleFunctions {
|
|
29
|
+
private readonly functions;
|
|
30
|
+
/** The functions being read, to end a function that returns what it returns itself. */
|
|
31
|
+
private readonly reading;
|
|
32
|
+
/** Whether each function returns a test object, read once however many calls and functions lead to it. */
|
|
33
|
+
private readonly read;
|
|
34
|
+
/** The values of variables the bindings left unbound, by name, which a call may make test objects. */
|
|
35
|
+
private readonly values;
|
|
36
|
+
/** Whether each of those holds a test object; null while that is being worked out, which ends a loop. */
|
|
37
|
+
private readonly built;
|
|
38
|
+
private readonly imported;
|
|
39
|
+
private readonly follow;
|
|
40
|
+
constructor(program: Program, imported: Map<string, ModuleImport>, follow: FollowImport);
|
|
41
|
+
/** Keeps the variables the bindings didn't bind without following calls, to be worked out when asked. */
|
|
42
|
+
keep(declarators: Iterable<VariableDeclarator>): void;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a variable holds a test object built by a call, as `const test = defineBrokerTest()`. It is worked out
|
|
45
|
+
* only when asked, as following the call reads other modules.
|
|
46
|
+
*/
|
|
47
|
+
holdsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
|
|
48
|
+
returnsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
|
|
18
49
|
}
|
|
19
50
|
export declare function topLevelDeclarators(program: Program): VariableDeclarator[];
|
|
20
51
|
/** All variable declarators in the file, at any depth, such as `const { it } = import.meta.vitest` inside an `if`. */
|
|
@@ -25,12 +56,12 @@ export declare function moduleImports(program: Program, isFramework: (source: st
|
|
|
25
56
|
export declare function requiredModule(expression: Expression): string | null;
|
|
26
57
|
/** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
|
|
27
58
|
export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
|
|
28
|
-
/** Whether a module exports `name` as a test object. */
|
|
29
|
-
export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
|
|
59
|
+
/** Whether a module exports `name` as a test object, or as a function that returns one. */
|
|
60
|
+
export declare function exportsTestObject(program: Program, name: string, objects: TestObjects, exported?: Exported): boolean;
|
|
30
61
|
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
31
62
|
export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
|
|
32
63
|
/** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
|
|
33
|
-
export type FollowImport = (source: string, imported: string) => boolean;
|
|
64
|
+
export type FollowImport = (source: string, imported: string, exported?: Exported) => boolean;
|
|
34
65
|
/** How a framework recognizes the modules and test objects of a followed file. */
|
|
35
66
|
export interface Framework {
|
|
36
67
|
name: string;
|
|
@@ -46,7 +77,7 @@ export declare function parseModule(module: ModuleSource): ParsedFile;
|
|
|
46
77
|
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
47
78
|
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
48
79
|
*/
|
|
49
|
-
export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
|
|
80
|
+
export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, exported?: Exported, path?: readonly string[], followed?: Map<string, Followed>): Followed;
|
|
50
81
|
interface Followed {
|
|
51
82
|
exported: boolean;
|
|
52
83
|
reason: string | null;
|