test-inventory 0.3.0 → 0.4.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 +10 -0
- package/README.md +6 -0
- package/dist/diagnostics.js +2 -0
- package/dist/extract.d.ts +26 -2
- package/dist/extract.js +178 -7
- package/dist/filter.d.ts +2 -1
- package/dist/filter.js +23 -7
- package/dist/frameworks/common.d.ts +4 -0
- package/dist/frameworks/common.js +22 -1
- package/dist/frameworks/playwright.d.ts +4 -0
- package/dist/frameworks/playwright.js +54 -7
- package/dist/frameworks/vitest.d.ts +7 -0
- package/dist/frameworks/vitest.js +137 -20
- package/dist/index.d.ts +1 -1
- package/dist/inventory.js +1 -0
- package/dist/json.js +2 -0
- package/dist/model.d.ts +39 -2
- package/dist/records.d.ts +7 -2
- package/dist/records.js +69 -1
- package/dist/scan-file.d.ts +2 -0
- package/dist/scan-file.js +12 -6
- package/dist/scan.js +2 -3
- package/dist/summary.js +1 -0
- package/dist/types.d.ts +95 -3
- package/package.json +1 -1
- package/schema/v1.json +326 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# test-inventory
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#42](https://github.com/Huldoser/test-inventory/pull/42) [`6849f55`](https://github.com/Huldoser/test-inventory/commit/6849f555d8b214ebbc8fdb9aa9cbac8cfe0913db) - Tests say what their body does: `fixtures` (the names the test and the `beforeEach`, `afterEach` and `aroundEach` hooks around it take from the context, without Vitest's own keys), `expectCount` (`expect()`, `expect.soft()` and `expect.poll()` calls in the body), Vitest's `expect.assertions(n)` and `expect.hasAssertions()` as `expectedAssertions` and `expectsAssertions`, `hasEmptyBody`, and Playwright's `test.step()` titles in `steps`. Vitest's `annotate()` from the test context adds annotations. Vitest 5 benchmarks, in `*.bench.ts` files or using the `bench` fixture, have `isBenchmark` and a `benchmarkCount` in the summary. New warnings: `empty-title` for a test or describe titled `''`, and `bench-outside-benchmark-file`.
|
|
8
|
+
|
|
9
|
+
- [#40](https://github.com/Huldoser/test-inventory/pull/40) [`f462ee0`](https://github.com/Huldoser/test-inventory/commit/f462ee015fea8ee802f567a26dbaa4db94ada1d5) - The output has a record for each scanned file in `files`, and counts hooks: `hooks` on a file holds the hooks at its top level, on a describe the hooks registered in it, and on a test every hook that runs around it, by kind (`beforeAll`, `beforeEach`, `afterEach`, `afterAll`, and Vitest's `aroundEach` and `aroundAll`). Hooks a helper function registers count where it is called. JSON Lines output has a `file` line for each file, and a filtered inventory keeps the files of the matched tests and the files without tests.
|
|
10
|
+
|
|
11
|
+
- [#41](https://github.com/Huldoser/test-inventory/pull/41) [`1ab9cb4`](https://github.com/Huldoser/test-inventory/commit/1ab9cb4002dd971ddb62570dc55ac564504e3a83) - Tests and describes have their run settings: `timeout`, `isSlow` and `slowCondition`, `retries`, `retryDelay`, `retryCondition` and `repeats`. Playwright's come from `test.describe.configure({ timeout, retries })`, `test.setTimeout()` and `test.slow()` in a describe, a `beforeEach` or the test body; Vitest's from the `timeout`, `retry` and `repeats` options, the timeout after the body, and the options a describe passes down. A test gets the value from the nearest place that sets it, and `null` when the file sets none, so the config decides. A timeout that can't be worked out, or that is set only under a condition, gets an `unresolved-option` warning.
|
|
12
|
+
|
|
3
13
|
## 0.3.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -86,6 +86,12 @@ const todo = inventory.tests.filter((test) => test.isTodo).map((test) => test.fu
|
|
|
86
86
|
`scanSource()` scans the code of one file without touching the disk. The JSON Schema of the output is exported as
|
|
87
87
|
`test-inventory/schema/v1.json`, so a reader can pin the version of the format it understands.
|
|
88
88
|
|
|
89
|
+
## AI agents
|
|
90
|
+
|
|
91
|
+
[test-inventory-mcp](https://github.com/Huldoser/test-inventory-mcp) is an MCP server built on test-inventory: agents
|
|
92
|
+
in Claude Code, Cursor, VS Code and other MCP clients ask it which tests a project has without running them. The
|
|
93
|
+
[AI agents](https://huldoser.github.io/test-inventory/guide/agents) page has the setup and the other ways in.
|
|
94
|
+
|
|
89
95
|
## Limits
|
|
90
96
|
|
|
91
97
|
- Config files are not read, so `grep`, projects and Vitest tag options in them do not change the output.
|
package/dist/diagnostics.js
CHANGED
|
@@ -34,6 +34,8 @@ export const LEVELS = {
|
|
|
34
34
|
'no-files-matched': 'warning',
|
|
35
35
|
'unsupported-file': 'warning',
|
|
36
36
|
'approximate-framework-version': 'warning',
|
|
37
|
+
'empty-title': 'warning',
|
|
38
|
+
'bench-outside-benchmark-file': 'warning',
|
|
37
39
|
'test-in-function': 'warning',
|
|
38
40
|
};
|
|
39
41
|
/**
|
package/dist/extract.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { CallExpression } from 'oxc-parser';
|
|
2
|
-
import type { Adapter, Details, Mode, ModuleReader, ModuleSource, Problem, Span, StateSetting, SuiteDeclaration, TestDeclaration } from './model.ts';
|
|
2
|
+
import type { Adapter, Details, Mode, ModuleReader, ModuleSource, Problem, RunSettings, Span, StateSetting, SuiteDeclaration, TestDeclaration } from './model.ts';
|
|
3
3
|
import type { ParsedFile } from './parse.ts';
|
|
4
4
|
import { type SourceText } from './source.ts';
|
|
5
|
-
import type { Annotation } from './types.ts';
|
|
5
|
+
import type { Annotation, HookCounts, Step } from './types.ts';
|
|
6
6
|
import { type Values } from './values.ts';
|
|
7
7
|
/** The file or a describe, with what is set on it. Tests inherit from every scope around them. */
|
|
8
8
|
export interface Scope {
|
|
@@ -12,6 +12,12 @@ export interface Scope {
|
|
|
12
12
|
mode: Mode | null;
|
|
13
13
|
isOnly: boolean;
|
|
14
14
|
details: Details;
|
|
15
|
+
/** Hooks registered directly in the scope. */
|
|
16
|
+
hooks: HookCounts;
|
|
17
|
+
/** Run settings from `beforeEach` hooks in the scope, which run before each test in it and win over the scope's own. */
|
|
18
|
+
hookRun: RunSettings;
|
|
19
|
+
/** Fixtures of the hooks in the scope that run around each test. */
|
|
20
|
+
hookFixtures: string[];
|
|
15
21
|
suite: PendingSuite | null;
|
|
16
22
|
}
|
|
17
23
|
/** Where a test or describe registered through an imported helper is written, in the helper's module. */
|
|
@@ -48,6 +54,12 @@ export interface PendingTest {
|
|
|
48
54
|
isInCondition: boolean;
|
|
49
55
|
isInFunction: boolean;
|
|
50
56
|
comments: string[];
|
|
57
|
+
/** Run settings from calls in the body, which win over everything around the test. */
|
|
58
|
+
run: RunSettings;
|
|
59
|
+
expectCount: number;
|
|
60
|
+
expectedAssertions: number | null;
|
|
61
|
+
expectsAssertions: boolean;
|
|
62
|
+
steps: Step[];
|
|
51
63
|
problems: Problem[];
|
|
52
64
|
}
|
|
53
65
|
/** A file the walk reads code from: the scanned file, or a module a helper function is imported from. */
|
|
@@ -65,7 +77,19 @@ export interface Extraction {
|
|
|
65
77
|
tests: PendingTest[];
|
|
66
78
|
/** Problems not tied to one test. */
|
|
67
79
|
problems: Problem[];
|
|
80
|
+
/** Hooks at the top level of the file. */
|
|
81
|
+
hooks: HookCounts;
|
|
68
82
|
usesGlobals: boolean;
|
|
69
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Applies settings written later in the same place: the last value of each wins, and a test is slow when any
|
|
86
|
+
* `test.slow()` applies.
|
|
87
|
+
*/
|
|
88
|
+
export declare function mergeRun(target: RunSettings, later: RunSettings): void;
|
|
89
|
+
/** Any of several `test.slow()` calls: always when one always applies, otherwise under any of their conditions. */
|
|
90
|
+
export declare function anySlow(slows: NonNullable<RunSettings['slow']>[]): NonNullable<RunSettings['slow']>;
|
|
91
|
+
/** Conditions joined with `||`, adding parentheses where a part could otherwise be read differently. */
|
|
92
|
+
export declare function eitherCondition(parts: string[]): string;
|
|
93
|
+
export declare function noHooks(): HookCounts;
|
|
70
94
|
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
71
95
|
export declare function extract(main: Frame, openModule: OpenModule): Extraction;
|
package/dist/extract.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
|
|
2
2
|
import { exportName } from "./frameworks/bindings.js";
|
|
3
|
-
import { problem } from "./frameworks/common.js";
|
|
3
|
+
import { problem, readCount } from "./frameworks/common.js";
|
|
4
4
|
import { snippet, splitLines } from "./source.js";
|
|
5
5
|
import { isScopeNode } from "./values.js";
|
|
6
6
|
/** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
|
|
@@ -20,11 +20,49 @@ function calledWhereWritten(call, written) {
|
|
|
20
20
|
!call.inBody);
|
|
21
21
|
}
|
|
22
22
|
const LOOP_METHODS = new Set(['forEach', 'map']);
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* What `test.describe.configure()` sets on the describe or file it is called in: the last mode, timeout and retries
|
|
25
|
+
* win, locks add up.
|
|
26
|
+
*/
|
|
24
27
|
function configure(scope, settings) {
|
|
25
28
|
if (settings.mode)
|
|
26
29
|
scope.mode = settings.mode;
|
|
27
30
|
scope.details.locks.push(...settings.locks);
|
|
31
|
+
mergeRun(scope.details.run, settings.run);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Applies settings written later in the same place: the last value of each wins, and a test is slow when any
|
|
35
|
+
* `test.slow()` applies.
|
|
36
|
+
*/
|
|
37
|
+
export function mergeRun(target, later) {
|
|
38
|
+
const { slow, ...rest } = later;
|
|
39
|
+
Object.assign(target, rest);
|
|
40
|
+
if (slow)
|
|
41
|
+
target.slow = target.slow ? anySlow([target.slow, slow]) : slow;
|
|
42
|
+
}
|
|
43
|
+
/** Any of several `test.slow()` calls: always when one always applies, otherwise under any of their conditions. */
|
|
44
|
+
export function anySlow(slows) {
|
|
45
|
+
const lists = slows.map((slow) => slow.conditions);
|
|
46
|
+
if (lists.includes(null))
|
|
47
|
+
return { conditions: null };
|
|
48
|
+
return { conditions: [...new Set(lists.flat())] };
|
|
49
|
+
}
|
|
50
|
+
/** Conditions joined with `||`, adding parentheses where a part could otherwise be read differently. */
|
|
51
|
+
export function eitherCondition(parts) {
|
|
52
|
+
return parts.map((part) => (parts.length > 1 && /\?|,/.test(part) ? `(${part})` : part)).join(' || ');
|
|
53
|
+
}
|
|
54
|
+
/** A warning for a test or describe titled with an empty string, which reports can't tell apart. */
|
|
55
|
+
function emptyTitle(declaration, call) {
|
|
56
|
+
const { title } = declaration;
|
|
57
|
+
if (title?.text !== '' || title.isDynamic)
|
|
58
|
+
return [];
|
|
59
|
+
const what = declaration.kind === 'test' ? 'test' : 'describe';
|
|
60
|
+
return [problem('empty-title', call, `The ${what} has an empty title, so reports and filters can't tell it apart.`)];
|
|
61
|
+
}
|
|
62
|
+
/** Hooks that run around each test, whose fixtures the test uses too. */
|
|
63
|
+
const EACH_HOOKS = new Set(['beforeEach', 'afterEach', 'aroundEach']);
|
|
64
|
+
export function noHooks() {
|
|
65
|
+
return { beforeAll: 0, beforeEach: 0, afterEach: 0, afterAll: 0, aroundEach: 0, aroundAll: 0 };
|
|
28
66
|
}
|
|
29
67
|
/** Joins conditions with `&&`, adding parentheses where a part could otherwise be read differently. */
|
|
30
68
|
function joinConditions(parts) {
|
|
@@ -95,7 +133,10 @@ class Walker {
|
|
|
95
133
|
states: [],
|
|
96
134
|
mode: null,
|
|
97
135
|
isOnly: false,
|
|
98
|
-
details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null },
|
|
136
|
+
details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null, run: {} },
|
|
137
|
+
hooks: noHooks(),
|
|
138
|
+
hookRun: {},
|
|
139
|
+
hookFixtures: [],
|
|
99
140
|
suite: null,
|
|
100
141
|
};
|
|
101
142
|
}
|
|
@@ -104,6 +145,8 @@ class Walker {
|
|
|
104
145
|
scope: this.fileScope,
|
|
105
146
|
test: null,
|
|
106
147
|
hookScope: null,
|
|
148
|
+
hookName: null,
|
|
149
|
+
stepDepth: 0,
|
|
107
150
|
testContext: null,
|
|
108
151
|
guards: [],
|
|
109
152
|
isInLoop: false,
|
|
@@ -127,6 +170,7 @@ class Walker {
|
|
|
127
170
|
suites: this.suites,
|
|
128
171
|
tests: this.tests,
|
|
129
172
|
problems: [...this.main.adapter.problems, ...this.problems],
|
|
173
|
+
hooks: this.fileScope.hooks,
|
|
130
174
|
usesGlobals: this.main.adapter.usesGlobals,
|
|
131
175
|
};
|
|
132
176
|
}
|
|
@@ -147,6 +191,8 @@ class Walker {
|
|
|
147
191
|
visitNode(node, context) {
|
|
148
192
|
switch (node.type) {
|
|
149
193
|
case 'CallExpression': {
|
|
194
|
+
if (context.test && !context.quiet)
|
|
195
|
+
this.expectCall(node, context.test);
|
|
150
196
|
const helper = this.helperCall(node, context);
|
|
151
197
|
if (this.call(node, context) || this.loopCall(node, context))
|
|
152
198
|
return;
|
|
@@ -342,13 +388,35 @@ class Walker {
|
|
|
342
388
|
configure(context.scope, api);
|
|
343
389
|
}
|
|
344
390
|
return true;
|
|
391
|
+
case 'run':
|
|
392
|
+
this.runSetting(call, api, context);
|
|
393
|
+
return true;
|
|
394
|
+
case 'step':
|
|
395
|
+
// The adapters only return steps from test bodies.
|
|
396
|
+
context.test.steps.push({
|
|
397
|
+
title: api.title.text,
|
|
398
|
+
hasDynamicTitle: api.title.isDynamic,
|
|
399
|
+
lineStart: this.lineOf(call),
|
|
400
|
+
depth: context.stepDepth,
|
|
401
|
+
isSkipped: api.isSkipped,
|
|
402
|
+
});
|
|
403
|
+
if (api.fn)
|
|
404
|
+
this.visitCallback(api.fn, { ...context, stepDepth: context.stepDepth + 1 });
|
|
405
|
+
return true;
|
|
345
406
|
case 'hook':
|
|
346
407
|
if (context.test)
|
|
347
408
|
return false;
|
|
409
|
+
// A function with a name registers its hooks where it is called, not where it is written.
|
|
410
|
+
if (!context.hookScope && context.declaredIn === null) {
|
|
411
|
+
context.scope.hooks[api.name] += 1;
|
|
412
|
+
if (EACH_HOOKS.has(api.name))
|
|
413
|
+
context.scope.hookFixtures.push(...api.fixtures);
|
|
414
|
+
}
|
|
348
415
|
if (api.fn) {
|
|
349
416
|
this.visitCallback(api.fn, {
|
|
350
417
|
...context,
|
|
351
418
|
hookScope: context.scope,
|
|
419
|
+
hookName: api.name,
|
|
352
420
|
testContext: api.testContext,
|
|
353
421
|
guards: [],
|
|
354
422
|
helper: null,
|
|
@@ -411,6 +479,7 @@ class Walker {
|
|
|
411
479
|
call,
|
|
412
480
|
fn,
|
|
413
481
|
target: context.test ?? context.hookScope,
|
|
482
|
+
hookName: context.test ? null : context.hookName,
|
|
414
483
|
guards: context.guards,
|
|
415
484
|
});
|
|
416
485
|
}
|
|
@@ -607,6 +676,89 @@ class Walker {
|
|
|
607
676
|
}
|
|
608
677
|
return chains.get(fn);
|
|
609
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Applies `test.setTimeout()` or `test.slow()` where it is called: in a test body to the test, in a `beforeEach` to
|
|
681
|
+
* the tests of its scope, at file or describe level to the scope. A function with a name applies them where it is
|
|
682
|
+
* called instead.
|
|
683
|
+
*/
|
|
684
|
+
runSetting(call, api, context) {
|
|
685
|
+
if (context.declaredIn !== null && !context.test) {
|
|
686
|
+
for (const item of api.problems)
|
|
687
|
+
this.report(item);
|
|
688
|
+
if (context.helper)
|
|
689
|
+
this.effectsOf(context.helper).push({ kind: 'run', settings: api.settings, argument: api.argument, call });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
|
|
693
|
+
problems.unshift(...api.problems);
|
|
694
|
+
if (context.test) {
|
|
695
|
+
context.test.problems.push(...problems.map((item) => this.here(item)));
|
|
696
|
+
mergeRun(context.test.run, settings);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
for (const item of problems)
|
|
700
|
+
this.report(item);
|
|
701
|
+
if (context.hookScope) {
|
|
702
|
+
// `beforeAll` and `afterAll` set the hook's own timeout, and an `afterEach` runs after the test body.
|
|
703
|
+
if (context.hookName === 'beforeEach')
|
|
704
|
+
mergeRun(context.hookScope.hookRun, settings);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
mergeRun(context.scope.details.run, settings);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Run settings under the conditions around a call: a slow test keeps them as its condition, while a timeout set only
|
|
711
|
+
* sometimes is left out with a warning.
|
|
712
|
+
*/
|
|
713
|
+
guardedRun(call, written, argument, guards) {
|
|
714
|
+
const settings = { ...written };
|
|
715
|
+
const problems = [];
|
|
716
|
+
const guard = joinConditions(guards);
|
|
717
|
+
if (settings.slow && guards.length > 0) {
|
|
718
|
+
const own = settings.slow.conditions ?? [null];
|
|
719
|
+
settings.slow = {
|
|
720
|
+
conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (settings.timeout !== undefined && guard !== null) {
|
|
724
|
+
const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
|
|
725
|
+
problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
|
|
726
|
+
delete settings.timeout;
|
|
727
|
+
}
|
|
728
|
+
return { settings, problems };
|
|
729
|
+
}
|
|
730
|
+
/** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
|
|
731
|
+
applyHelperRun(call, effect) {
|
|
732
|
+
const { settings, problems } = this.guardedRun(call.call, effect.settings, effect.argument, effect.guards);
|
|
733
|
+
if (call.hookName === null) {
|
|
734
|
+
const test = call.target;
|
|
735
|
+
test.problems.push(...problems);
|
|
736
|
+
mergeRun(test.run, settings);
|
|
737
|
+
}
|
|
738
|
+
else if (call.hookName === 'beforeEach') {
|
|
739
|
+
this.problems.push(...problems);
|
|
740
|
+
mergeRun(call.target.hookRun, settings);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/** Counts `expect()`, `expect.soft()` and `expect.poll()`, and reads `expect.assertions(n)` and `expect.hasAssertions()`. */
|
|
744
|
+
expectCall(call, test) {
|
|
745
|
+
const callee = unwrap(call.callee);
|
|
746
|
+
const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
|
|
747
|
+
if (root.type !== 'Identifier' || root.name !== 'expect')
|
|
748
|
+
return;
|
|
749
|
+
if (member === 'call' || member === 'soft' || member === 'poll')
|
|
750
|
+
test.expectCount += 1;
|
|
751
|
+
if (member === 'hasAssertions')
|
|
752
|
+
test.expectsAssertions = true;
|
|
753
|
+
const count = call.arguments.at(0);
|
|
754
|
+
if (member === 'assertions' && count && count.type !== 'SpreadElement') {
|
|
755
|
+
const file = { source: this.frame.parsed.source, values: this.frame.values, version: null };
|
|
756
|
+
const option = { node: count, resolved: this.frame.values.resolve(count, this.scopes) };
|
|
757
|
+
const problems = [];
|
|
758
|
+
test.expectedAssertions = readCount(file, 'expect.assertions', option, problems);
|
|
759
|
+
test.problems.push(...problems.map((item) => this.here(item)));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
610
762
|
/** Reports a problem not tied to a test, once, however many times a function with it is walked. */
|
|
611
763
|
report(found) {
|
|
612
764
|
const item = this.here(found);
|
|
@@ -688,6 +840,8 @@ class Walker {
|
|
|
688
840
|
return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
|
|
689
841
|
if (effect.kind === 'configure')
|
|
690
842
|
return [effect];
|
|
843
|
+
if (effect.kind === 'run')
|
|
844
|
+
return [{ ...effect, guards }];
|
|
691
845
|
const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
|
|
692
846
|
// Helpers are expanded here only for calls from tests and hooks.
|
|
693
847
|
return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
|
|
@@ -700,6 +854,8 @@ class Walker {
|
|
|
700
854
|
// `describe.configure()` does nothing in a test or hook body.
|
|
701
855
|
if (effect.kind === 'state')
|
|
702
856
|
call.target.states.push(effect.setting);
|
|
857
|
+
if (effect.kind === 'run')
|
|
858
|
+
this.applyHelperRun(call, effect);
|
|
703
859
|
}
|
|
704
860
|
if (steps > MAX_CALLS) {
|
|
705
861
|
const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
|
|
@@ -754,6 +910,7 @@ class Walker {
|
|
|
754
910
|
if (context.quiet || this.nested(call, context))
|
|
755
911
|
return;
|
|
756
912
|
const statement = this.statement(call);
|
|
913
|
+
const empty = emptyTitle(declaration, call);
|
|
757
914
|
const test = {
|
|
758
915
|
scope: context.scope,
|
|
759
916
|
declaration,
|
|
@@ -767,17 +924,26 @@ class Walker {
|
|
|
767
924
|
isInCondition: context.isInCondition,
|
|
768
925
|
isInFunction: context.isInFunction,
|
|
769
926
|
comments: this.commentsAbove(statement.start),
|
|
770
|
-
|
|
927
|
+
run: {},
|
|
928
|
+
expectCount: 0,
|
|
929
|
+
expectedAssertions: null,
|
|
930
|
+
expectsAssertions: false,
|
|
931
|
+
steps: [],
|
|
932
|
+
problems: [...declaration.problems, ...empty].map((item) => this.here(item)),
|
|
771
933
|
};
|
|
772
934
|
this.tests.push(test);
|
|
773
935
|
if (context.declaredIn)
|
|
774
936
|
this.declaredIn.set(test, context.declaredIn);
|
|
775
937
|
this.cut(context.scope, call);
|
|
776
|
-
|
|
777
|
-
|
|
938
|
+
// A function declared in the file and passed by name runs as the test body too.
|
|
939
|
+
const body = declaration.fn ?? declaration.hashed;
|
|
940
|
+
if (body) {
|
|
941
|
+
this.visitCallback(body, {
|
|
778
942
|
...context,
|
|
779
943
|
test,
|
|
780
944
|
hookScope: null,
|
|
945
|
+
hookName: null,
|
|
946
|
+
stepDepth: 0,
|
|
781
947
|
testContext: declaration.testContext,
|
|
782
948
|
guards: [],
|
|
783
949
|
helper: null,
|
|
@@ -787,7 +953,7 @@ class Walker {
|
|
|
787
953
|
declareSuite(call, declaration, context) {
|
|
788
954
|
if (context.quiet || this.nested(call, context))
|
|
789
955
|
return;
|
|
790
|
-
for (const item of declaration.problems)
|
|
956
|
+
for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
|
|
791
957
|
this.report(item);
|
|
792
958
|
const scope = {
|
|
793
959
|
kind: 'describe',
|
|
@@ -796,6 +962,9 @@ class Walker {
|
|
|
796
962
|
mode: declaration.mode,
|
|
797
963
|
isOnly: declaration.isOnly,
|
|
798
964
|
details: declaration.details,
|
|
965
|
+
hooks: noHooks(),
|
|
966
|
+
hookRun: {},
|
|
967
|
+
hookFixtures: [],
|
|
799
968
|
suite: null,
|
|
800
969
|
};
|
|
801
970
|
const suite = {
|
|
@@ -817,6 +986,8 @@ class Walker {
|
|
|
817
986
|
scope,
|
|
818
987
|
test: null,
|
|
819
988
|
hookScope: null,
|
|
989
|
+
hookName: null,
|
|
990
|
+
stepDepth: 0,
|
|
820
991
|
testContext: null,
|
|
821
992
|
guards: [],
|
|
822
993
|
helper: null,
|
package/dist/filter.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests
|
|
|
20
20
|
export declare function matcher(filter: InventoryFilter): (test: TestRecord) => boolean;
|
|
21
21
|
/**
|
|
22
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
|
|
23
|
+
* result describes only those tests: their files, the describes around them, their diagnostics, and a summary
|
|
24
|
+
* counted over them. Files without any tests stay, with their diagnostics, since tests may be missing from them.
|
|
24
25
|
* `summary.fileCount` stays the number of files scanned.
|
|
25
26
|
*
|
|
26
27
|
* Throws a `TypeError` for an invalid filter.
|
package/dist/filter.js
CHANGED
|
@@ -47,6 +47,21 @@ const TEST_KEYS = Object.keys({
|
|
|
47
47
|
comments: true,
|
|
48
48
|
locks: true,
|
|
49
49
|
meta: true,
|
|
50
|
+
hooks: true,
|
|
51
|
+
timeout: true,
|
|
52
|
+
isSlow: true,
|
|
53
|
+
slowCondition: true,
|
|
54
|
+
retries: true,
|
|
55
|
+
retryDelay: true,
|
|
56
|
+
retryCondition: true,
|
|
57
|
+
repeats: true,
|
|
58
|
+
fixtures: true,
|
|
59
|
+
expectCount: true,
|
|
60
|
+
expectedAssertions: true,
|
|
61
|
+
expectsAssertions: true,
|
|
62
|
+
hasEmptyBody: true,
|
|
63
|
+
steps: true,
|
|
64
|
+
isBenchmark: true,
|
|
50
65
|
isParameterized: true,
|
|
51
66
|
caseCount: true,
|
|
52
67
|
isInLoop: true,
|
|
@@ -103,7 +118,8 @@ export function matcher(filter) {
|
|
|
103
118
|
}
|
|
104
119
|
/**
|
|
105
120
|
* 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
|
|
121
|
+
* result describes only those tests: their files, the describes around them, their diagnostics, and a summary
|
|
122
|
+
* counted over them. Files without any tests stay, with their diagnostics, since tests may be missing from them.
|
|
107
123
|
* `summary.fileCount` stays the number of files scanned.
|
|
108
124
|
*
|
|
109
125
|
* Throws a `TypeError` for an invalid filter.
|
|
@@ -121,13 +137,13 @@ export function filterInventory(inventory, filter) {
|
|
|
121
137
|
const suites = inventory.suites.filter((suite) => suiteIds.has(suite.id));
|
|
122
138
|
const filesWithTests = new Set(inventory.tests.map((test) => test.relativeFilePath));
|
|
123
139
|
const filesWithMatches = new Set(tests.map((test) => test.relativeFilePath));
|
|
124
|
-
// A problem that is not about one test
|
|
125
|
-
// with no tests, or in package.json, may mean tests are missing from the answer.
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
: testIds.has(diagnostic.testId));
|
|
140
|
+
// A file, and a problem that is not about one test, stay unless every test of the file was left out: an error in
|
|
141
|
+
// a file with no tests, or in package.json, may mean tests are missing from the answer.
|
|
142
|
+
const keepsFile = (relativeFilePath) => !filesWithTests.has(relativeFilePath) || filesWithMatches.has(relativeFilePath);
|
|
143
|
+
const diagnostics = inventory.diagnostics.filter((diagnostic) => diagnostic.testId === null ? keepsFile(diagnostic.relativeFilePath) : testIds.has(diagnostic.testId));
|
|
129
144
|
const summary = summarize(inventory.summary.fileCount, suites, tests, diagnostics);
|
|
130
|
-
|
|
145
|
+
const files = inventory.files.filter((file) => keepsFile(file.relativeFilePath));
|
|
146
|
+
return { ...inventory, summary, files, suites, tests, diagnostics };
|
|
131
147
|
}
|
|
132
148
|
/** Checks the keys to pick and returns them after `id`, each once. */
|
|
133
149
|
export function checkFields(fields) {
|
|
@@ -112,6 +112,10 @@ export interface Options {
|
|
|
112
112
|
export declare function readOptions(file: FileContext, scopes: readonly Node[], node: Argument): Options;
|
|
113
113
|
/** Warnings for the parts of an options object that could not be worked out, with what is missing as a result. */
|
|
114
114
|
export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
|
|
115
|
+
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
116
|
+
export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
|
|
117
|
+
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
118
|
+
export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
|
|
115
119
|
/** A warning for one option whose value could not be worked out. */
|
|
116
120
|
export declare function unresolvedOption(file: FileContext, name: string, node: Node, importedFrom: string | null, result: string): Problem;
|
|
117
121
|
export interface Strings {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isFunctionNode, isStringLiteral, propertyName, unwrap } from "../ast.js";
|
|
1
|
+
import { isFunctionNode, isStringLiteral, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
3
|
import { isKey } from "../values.js";
|
|
4
4
|
export function readChain(callee) {
|
|
@@ -275,6 +275,27 @@ export function unresolvedOptions(file, options, result) {
|
|
|
275
275
|
return problem('unresolved-option', node, `Options object ${source}${origin(importedFrom)} could not be worked out; ${result}.`, source);
|
|
276
276
|
});
|
|
277
277
|
}
|
|
278
|
+
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
279
|
+
export function destructuredNames(fn, index) {
|
|
280
|
+
const parameter = index === null ? undefined : fn?.params[index];
|
|
281
|
+
if (parameter?.type !== 'ObjectPattern')
|
|
282
|
+
return [];
|
|
283
|
+
const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
|
|
284
|
+
return names.filter((name) => name !== null);
|
|
285
|
+
}
|
|
286
|
+
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
287
|
+
export function readCount(file, name, option, problems) {
|
|
288
|
+
const { node, resolved } = option;
|
|
289
|
+
if (!resolved.ok) {
|
|
290
|
+
problems.push(unresolvedOption(file, name, node, resolved.importedFrom, 'it is not applied'));
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (Number.isInteger(resolved.value) && resolved.value >= 0)
|
|
294
|
+
return resolved.value;
|
|
295
|
+
const source = snippet(text(file, node));
|
|
296
|
+
problems.push(problem('unresolved-option', node, `Option ${name}: ${source} is not a whole number; it is not applied.`, source));
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
278
299
|
/** A warning for one option whose value could not be worked out. */
|
|
279
300
|
export function unresolvedOption(file, name, node, importedFrom, result) {
|
|
280
301
|
const source = snippet(text(file, node));
|
|
@@ -52,9 +52,13 @@ export declare class PlaywrightAdapter implements Adapter {
|
|
|
52
52
|
/** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
|
|
53
53
|
private invalid;
|
|
54
54
|
private testCall;
|
|
55
|
+
/** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
|
|
56
|
+
private step;
|
|
55
57
|
private modifier;
|
|
56
58
|
private describeCall;
|
|
57
59
|
private configure;
|
|
60
|
+
/** `test.setTimeout(ms)` or `test.slow(condition?, description?)`, which apply where they are called. */
|
|
61
|
+
private runSetting;
|
|
58
62
|
/** `test.info().annotations.push(...)` inside a test body. */
|
|
59
63
|
private annotationsPush;
|
|
60
64
|
private option;
|
|
@@ -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, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
4
|
+
import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, 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([
|
|
@@ -24,6 +24,8 @@ const STATES = new Map([
|
|
|
24
24
|
]);
|
|
25
25
|
/** Members of `test` that are known but don't declare or modify tests. */
|
|
26
26
|
const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
|
|
27
|
+
/** Members that set how the tests run where they are called. */
|
|
28
|
+
const RUN_MEMBERS = new Set(['slow', 'setTimeout']);
|
|
27
29
|
/** First members of chains that never declare a test, even when Playwright refuses the rest of the chain. */
|
|
28
30
|
const NOT_DECLARING = new Set(['step', 'abort', ...HOOKS, ...OTHER_MEMBERS]);
|
|
29
31
|
/** First members that show a name imported from a local file is used as Playwright's `test`. */
|
|
@@ -238,6 +240,9 @@ export class PlaywrightAdapter {
|
|
|
238
240
|
}
|
|
239
241
|
if (names[0] === 'info')
|
|
240
242
|
return this.annotationsPush(call, links, context);
|
|
243
|
+
if ((key === 'step' || key === 'step.skip') && context.inTest && call.arguments.length > 0) {
|
|
244
|
+
return this.step(call, key === 'step.skip', context);
|
|
245
|
+
}
|
|
241
246
|
if (names[0] === 'expect' || names[0] === 'step' || key === 'abort')
|
|
242
247
|
return { kind: 'other' };
|
|
243
248
|
if (links.some((link) => link.args ?? link.template)) {
|
|
@@ -251,8 +256,12 @@ export class PlaywrightAdapter {
|
|
|
251
256
|
return this.describeCall(call, describe, context);
|
|
252
257
|
if (key === 'describe.configure')
|
|
253
258
|
return this.configure(call, context);
|
|
254
|
-
if (HOOKS.has(key))
|
|
255
|
-
|
|
259
|
+
if (HOOKS.has(key)) {
|
|
260
|
+
const fn = findFunction(call.arguments);
|
|
261
|
+
return { kind: 'hook', name: key, fn, testContext: null, fixtures: destructuredNames(fn, 0) };
|
|
262
|
+
}
|
|
263
|
+
if (RUN_MEMBERS.has(key))
|
|
264
|
+
return this.runSetting(call, key, context);
|
|
256
265
|
if (OTHER_MEMBERS.has(key))
|
|
257
266
|
return { kind: 'other' };
|
|
258
267
|
return this.invalid(call, names, "is not part of Playwright's API", context);
|
|
@@ -451,6 +460,7 @@ export class PlaywrightAdapter {
|
|
|
451
460
|
problems.push(titleProblem);
|
|
452
461
|
const line = this.file.source.line(call.start);
|
|
453
462
|
const fn = isFunctionNode(last) ? last : null;
|
|
463
|
+
const hashed = fn ?? namedFunction(this.file, context.scopes, last);
|
|
454
464
|
return {
|
|
455
465
|
kind: 'test',
|
|
456
466
|
title,
|
|
@@ -459,13 +469,23 @@ export class PlaywrightAdapter {
|
|
|
459
469
|
mode: null,
|
|
460
470
|
details: this.details(args.length === 3 ? args[1] : undefined, context, problems, title.text),
|
|
461
471
|
fn,
|
|
462
|
-
hashed
|
|
472
|
+
hashed,
|
|
463
473
|
isParameterized: false,
|
|
464
474
|
caseCount: null,
|
|
465
475
|
testContext: null,
|
|
476
|
+
fixtures: destructuredNames(hashed, 0),
|
|
477
|
+
usesBench: false,
|
|
466
478
|
problems,
|
|
467
479
|
};
|
|
468
480
|
}
|
|
481
|
+
/** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
|
|
482
|
+
step(call, isSkipped, context) {
|
|
483
|
+
const { title } = readTitle(this.file, context.scopes, call.arguments[0], {
|
|
484
|
+
stripTags: false,
|
|
485
|
+
functionNames: false,
|
|
486
|
+
});
|
|
487
|
+
return { kind: 'step', title, isSkipped, fn: findFunction(call.arguments, 1) };
|
|
488
|
+
}
|
|
469
489
|
modifier(call, state, context) {
|
|
470
490
|
const [condition, description] = [call.arguments.at(0), call.arguments.at(1)];
|
|
471
491
|
const problems = [];
|
|
@@ -542,10 +562,37 @@ export class PlaywrightAdapter {
|
|
|
542
562
|
problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
|
|
543
563
|
}
|
|
544
564
|
}
|
|
545
|
-
|
|
565
|
+
const run = {};
|
|
566
|
+
const timeout = options?.values.get('timeout');
|
|
567
|
+
const timeoutValue = timeout ? readCount(this.file, 'timeout', timeout, problems) : null;
|
|
568
|
+
if (timeoutValue !== null)
|
|
569
|
+
run.timeout = timeoutValue;
|
|
570
|
+
const retries = options?.values.get('retries');
|
|
571
|
+
const count = retries ? readCount(this.file, 'retries', retries, problems) : null;
|
|
572
|
+
if (count !== null)
|
|
573
|
+
run.retry = { count, delay: null, condition: null };
|
|
574
|
+
if (!mode && locks.length === 0 && Object.keys(run).length === 0) {
|
|
546
575
|
return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
|
|
547
576
|
}
|
|
548
|
-
return { kind: 'configure', mode, locks, problems };
|
|
577
|
+
return { kind: 'configure', mode, locks, run, problems };
|
|
578
|
+
}
|
|
579
|
+
/** `test.setTimeout(ms)` or `test.slow(condition?, description?)`, which apply where they are called. */
|
|
580
|
+
runSetting(call, key, context) {
|
|
581
|
+
const problems = [];
|
|
582
|
+
const settings = {};
|
|
583
|
+
const first = call.arguments.at(0);
|
|
584
|
+
if (key === 'slow') {
|
|
585
|
+
const condition = readCondition(this.file, context.scopes, first);
|
|
586
|
+
if (condition.applies !== 'never')
|
|
587
|
+
settings.slow = { conditions: condition.text === null ? null : [condition.text] };
|
|
588
|
+
}
|
|
589
|
+
else if (first && first.type !== 'SpreadElement') {
|
|
590
|
+
const option = { node: first, resolved: this.file.values.resolve(first, context.scopes) };
|
|
591
|
+
const timeout = readCount(this.file, 'timeout', option, problems);
|
|
592
|
+
if (timeout !== null)
|
|
593
|
+
settings.timeout = timeout;
|
|
594
|
+
}
|
|
595
|
+
return { kind: 'run', settings, argument: first ? snippet(text(this.file, first)) : null, problems };
|
|
549
596
|
}
|
|
550
597
|
/** `test.info().annotations.push(...)` inside a test body. */
|
|
551
598
|
annotationsPush(call, links, context) {
|
|
@@ -561,7 +608,7 @@ export class PlaywrightAdapter {
|
|
|
561
608
|
}
|
|
562
609
|
/** Reads the `{ tag, annotation, lock }` details of a test or describe. */
|
|
563
610
|
details(node, context, problems, title) {
|
|
564
|
-
const details = { tags: titleTags(title), annotations: [], locks: [], meta: null };
|
|
611
|
+
const details = { tags: titleTags(title), annotations: [], locks: [], meta: null, run: {} };
|
|
565
612
|
if (!node)
|
|
566
613
|
return details;
|
|
567
614
|
const options = readOptions(this.file, context.scopes, node);
|