test-inventory 0.3.0 → 0.4.1
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 +25 -0
- package/README.md +6 -0
- package/dist/diagnostics.js +2 -0
- package/dist/extract.d.ts +26 -2
- package/dist/extract.js +186 -8
- package/dist/filter.d.ts +2 -1
- package/dist/filter.js +23 -7
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +26 -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 +141 -24
- 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/resolve.js +3 -3
- package/dist/scan-file.d.ts +2 -0
- package/dist/scan-file.js +12 -6
- package/dist/scan.js +7 -4
- 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,30 @@
|
|
|
1
1
|
# test-inventory
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#48](https://github.com/Huldoser/test-inventory/pull/48) [`6549682`](https://github.com/Huldoser/test-inventory/commit/65496820548c0980cef2f290f6d54bd89160236b) - A hook given a function the file declares, as in `test.beforeEach(skipOnCI)` or `beforeEach(requireBroker)`, is read
|
|
8
|
+
like one written in place: the skips, timeouts and other settings it sets apply to the tests it runs before, and its
|
|
9
|
+
fixtures are in their `fixtures`. Before, they were missed. A test or hook function passed by name no longer gets a
|
|
10
|
+
`state-in-helper` warning saying no test, hook or describe in the file calls it.
|
|
11
|
+
|
|
12
|
+
- [#47](https://github.com/Huldoser/test-inventory/pull/47) [`a603623`](https://github.com/Huldoser/test-inventory/commit/a603623b2431e51c3e48ae0413964d5a7125de59) - A `#` import or workspace package export whose target repeats the `*` of its pattern, as in
|
|
13
|
+
`"#desks/*": "./desks/*/*-fixtures.ts"`, is followed to the file Node.js loads, with every `*` filled in. Only the
|
|
14
|
+
first was filled in before, so the import wasn't followed and its tests were missing, with an `unresolved-test-import`
|
|
15
|
+
error. A folder given as the pattern with a long run of slashes, as in `tests////unit`, no longer slows the scan down
|
|
16
|
+
while the hint for it is written.
|
|
17
|
+
|
|
18
|
+
## 0.4.0
|
|
19
|
+
|
|
20
|
+
### Minor Changes
|
|
21
|
+
|
|
22
|
+
- [#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`.
|
|
23
|
+
|
|
24
|
+
- [#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.
|
|
25
|
+
|
|
26
|
+
- [#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.
|
|
27
|
+
|
|
3
28
|
## 0.3.0
|
|
4
29
|
|
|
5
30
|
### 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) {
|
|
@@ -72,7 +110,10 @@ class Walker {
|
|
|
72
110
|
declaredIn = new Map();
|
|
73
111
|
deferred = [];
|
|
74
112
|
isDeferred = new Set();
|
|
75
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Functions walked at one of their calls, or as a test or hook body passed by name, so that their tests are recorded
|
|
115
|
+
* where they are registered and what they set isn't reported as unapplied.
|
|
116
|
+
*/
|
|
76
117
|
walkedAtCall = new Set();
|
|
77
118
|
/** The functions being walked at their calls, innermost last, to stop recursion. */
|
|
78
119
|
callStack = [];
|
|
@@ -95,7 +136,10 @@ class Walker {
|
|
|
95
136
|
states: [],
|
|
96
137
|
mode: null,
|
|
97
138
|
isOnly: false,
|
|
98
|
-
details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null },
|
|
139
|
+
details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null, run: {} },
|
|
140
|
+
hooks: noHooks(),
|
|
141
|
+
hookRun: {},
|
|
142
|
+
hookFixtures: [],
|
|
99
143
|
suite: null,
|
|
100
144
|
};
|
|
101
145
|
}
|
|
@@ -104,6 +148,8 @@ class Walker {
|
|
|
104
148
|
scope: this.fileScope,
|
|
105
149
|
test: null,
|
|
106
150
|
hookScope: null,
|
|
151
|
+
hookName: null,
|
|
152
|
+
stepDepth: 0,
|
|
107
153
|
testContext: null,
|
|
108
154
|
guards: [],
|
|
109
155
|
isInLoop: false,
|
|
@@ -127,6 +173,7 @@ class Walker {
|
|
|
127
173
|
suites: this.suites,
|
|
128
174
|
tests: this.tests,
|
|
129
175
|
problems: [...this.main.adapter.problems, ...this.problems],
|
|
176
|
+
hooks: this.fileScope.hooks,
|
|
130
177
|
usesGlobals: this.main.adapter.usesGlobals,
|
|
131
178
|
};
|
|
132
179
|
}
|
|
@@ -147,6 +194,8 @@ class Walker {
|
|
|
147
194
|
visitNode(node, context) {
|
|
148
195
|
switch (node.type) {
|
|
149
196
|
case 'CallExpression': {
|
|
197
|
+
if (context.test && !context.quiet)
|
|
198
|
+
this.expectCall(node, context.test);
|
|
150
199
|
const helper = this.helperCall(node, context);
|
|
151
200
|
if (this.call(node, context) || this.loopCall(node, context))
|
|
152
201
|
return;
|
|
@@ -342,13 +391,37 @@ class Walker {
|
|
|
342
391
|
configure(context.scope, api);
|
|
343
392
|
}
|
|
344
393
|
return true;
|
|
394
|
+
case 'run':
|
|
395
|
+
this.runSetting(call, api, context);
|
|
396
|
+
return true;
|
|
397
|
+
case 'step':
|
|
398
|
+
// The adapters only return steps from test bodies.
|
|
399
|
+
context.test.steps.push({
|
|
400
|
+
title: api.title.text,
|
|
401
|
+
hasDynamicTitle: api.title.isDynamic,
|
|
402
|
+
lineStart: this.lineOf(call),
|
|
403
|
+
depth: context.stepDepth,
|
|
404
|
+
isSkipped: api.isSkipped,
|
|
405
|
+
});
|
|
406
|
+
if (api.fn)
|
|
407
|
+
this.visitCallback(api.fn, { ...context, stepDepth: context.stepDepth + 1 });
|
|
408
|
+
return true;
|
|
345
409
|
case 'hook':
|
|
346
410
|
if (context.test)
|
|
347
411
|
return false;
|
|
412
|
+
// A function with a name registers its hooks where it is called, not where it is written.
|
|
413
|
+
if (!context.hookScope && context.declaredIn === null) {
|
|
414
|
+
context.scope.hooks[api.name] += 1;
|
|
415
|
+
if (EACH_HOOKS.has(api.name))
|
|
416
|
+
context.scope.hookFixtures.push(...api.fixtures);
|
|
417
|
+
}
|
|
348
418
|
if (api.fn) {
|
|
419
|
+
if (!call.arguments.includes(api.fn))
|
|
420
|
+
this.walkedAtCall.add(api.fn);
|
|
349
421
|
this.visitCallback(api.fn, {
|
|
350
422
|
...context,
|
|
351
423
|
hookScope: context.scope,
|
|
424
|
+
hookName: api.name,
|
|
352
425
|
testContext: api.testContext,
|
|
353
426
|
guards: [],
|
|
354
427
|
helper: null,
|
|
@@ -411,6 +484,7 @@ class Walker {
|
|
|
411
484
|
call,
|
|
412
485
|
fn,
|
|
413
486
|
target: context.test ?? context.hookScope,
|
|
487
|
+
hookName: context.test ? null : context.hookName,
|
|
414
488
|
guards: context.guards,
|
|
415
489
|
});
|
|
416
490
|
}
|
|
@@ -607,6 +681,89 @@ class Walker {
|
|
|
607
681
|
}
|
|
608
682
|
return chains.get(fn);
|
|
609
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Applies `test.setTimeout()` or `test.slow()` where it is called: in a test body to the test, in a `beforeEach` to
|
|
686
|
+
* the tests of its scope, at file or describe level to the scope. A function with a name applies them where it is
|
|
687
|
+
* called instead.
|
|
688
|
+
*/
|
|
689
|
+
runSetting(call, api, context) {
|
|
690
|
+
if (context.declaredIn !== null && !context.test) {
|
|
691
|
+
for (const item of api.problems)
|
|
692
|
+
this.report(item);
|
|
693
|
+
if (context.helper)
|
|
694
|
+
this.effectsOf(context.helper).push({ kind: 'run', settings: api.settings, argument: api.argument, call });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
|
|
698
|
+
problems.unshift(...api.problems);
|
|
699
|
+
if (context.test) {
|
|
700
|
+
context.test.problems.push(...problems.map((item) => this.here(item)));
|
|
701
|
+
mergeRun(context.test.run, settings);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
for (const item of problems)
|
|
705
|
+
this.report(item);
|
|
706
|
+
if (context.hookScope) {
|
|
707
|
+
// `beforeAll` and `afterAll` set the hook's own timeout, and an `afterEach` runs after the test body.
|
|
708
|
+
if (context.hookName === 'beforeEach')
|
|
709
|
+
mergeRun(context.hookScope.hookRun, settings);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
mergeRun(context.scope.details.run, settings);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Run settings under the conditions around a call: a slow test keeps them as its condition, while a timeout set only
|
|
716
|
+
* sometimes is left out with a warning.
|
|
717
|
+
*/
|
|
718
|
+
guardedRun(call, written, argument, guards) {
|
|
719
|
+
const settings = { ...written };
|
|
720
|
+
const problems = [];
|
|
721
|
+
const guard = joinConditions(guards);
|
|
722
|
+
if (settings.slow && guards.length > 0) {
|
|
723
|
+
const own = settings.slow.conditions ?? [null];
|
|
724
|
+
settings.slow = {
|
|
725
|
+
conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (settings.timeout !== undefined && guard !== null) {
|
|
729
|
+
const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
|
|
730
|
+
problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
|
|
731
|
+
delete settings.timeout;
|
|
732
|
+
}
|
|
733
|
+
return { settings, problems };
|
|
734
|
+
}
|
|
735
|
+
/** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
|
|
736
|
+
applyHelperRun(call, effect) {
|
|
737
|
+
const { settings, problems } = this.guardedRun(call.call, effect.settings, effect.argument, effect.guards);
|
|
738
|
+
if (call.hookName === null) {
|
|
739
|
+
const test = call.target;
|
|
740
|
+
test.problems.push(...problems);
|
|
741
|
+
mergeRun(test.run, settings);
|
|
742
|
+
}
|
|
743
|
+
else if (call.hookName === 'beforeEach') {
|
|
744
|
+
this.problems.push(...problems);
|
|
745
|
+
mergeRun(call.target.hookRun, settings);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/** Counts `expect()`, `expect.soft()` and `expect.poll()`, and reads `expect.assertions(n)` and `expect.hasAssertions()`. */
|
|
749
|
+
expectCall(call, test) {
|
|
750
|
+
const callee = unwrap(call.callee);
|
|
751
|
+
const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
|
|
752
|
+
if (root.type !== 'Identifier' || root.name !== 'expect')
|
|
753
|
+
return;
|
|
754
|
+
if (member === 'call' || member === 'soft' || member === 'poll')
|
|
755
|
+
test.expectCount += 1;
|
|
756
|
+
if (member === 'hasAssertions')
|
|
757
|
+
test.expectsAssertions = true;
|
|
758
|
+
const count = call.arguments.at(0);
|
|
759
|
+
if (member === 'assertions' && count && count.type !== 'SpreadElement') {
|
|
760
|
+
const file = { source: this.frame.parsed.source, values: this.frame.values, version: null };
|
|
761
|
+
const option = { node: count, resolved: this.frame.values.resolve(count, this.scopes) };
|
|
762
|
+
const problems = [];
|
|
763
|
+
test.expectedAssertions = readCount(file, 'expect.assertions', option, problems);
|
|
764
|
+
test.problems.push(...problems.map((item) => this.here(item)));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
610
767
|
/** Reports a problem not tied to a test, once, however many times a function with it is walked. */
|
|
611
768
|
report(found) {
|
|
612
769
|
const item = this.here(found);
|
|
@@ -688,6 +845,8 @@ class Walker {
|
|
|
688
845
|
return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
|
|
689
846
|
if (effect.kind === 'configure')
|
|
690
847
|
return [effect];
|
|
848
|
+
if (effect.kind === 'run')
|
|
849
|
+
return [{ ...effect, guards }];
|
|
691
850
|
const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
|
|
692
851
|
// Helpers are expanded here only for calls from tests and hooks.
|
|
693
852
|
return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
|
|
@@ -700,6 +859,8 @@ class Walker {
|
|
|
700
859
|
// `describe.configure()` does nothing in a test or hook body.
|
|
701
860
|
if (effect.kind === 'state')
|
|
702
861
|
call.target.states.push(effect.setting);
|
|
862
|
+
if (effect.kind === 'run')
|
|
863
|
+
this.applyHelperRun(call, effect);
|
|
703
864
|
}
|
|
704
865
|
if (steps > MAX_CALLS) {
|
|
705
866
|
const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
|
|
@@ -754,6 +915,7 @@ class Walker {
|
|
|
754
915
|
if (context.quiet || this.nested(call, context))
|
|
755
916
|
return;
|
|
756
917
|
const statement = this.statement(call);
|
|
918
|
+
const empty = emptyTitle(declaration, call);
|
|
757
919
|
const test = {
|
|
758
920
|
scope: context.scope,
|
|
759
921
|
declaration,
|
|
@@ -767,17 +929,28 @@ class Walker {
|
|
|
767
929
|
isInCondition: context.isInCondition,
|
|
768
930
|
isInFunction: context.isInFunction,
|
|
769
931
|
comments: this.commentsAbove(statement.start),
|
|
770
|
-
|
|
932
|
+
run: {},
|
|
933
|
+
expectCount: 0,
|
|
934
|
+
expectedAssertions: null,
|
|
935
|
+
expectsAssertions: false,
|
|
936
|
+
steps: [],
|
|
937
|
+
problems: [...declaration.problems, ...empty].map((item) => this.here(item)),
|
|
771
938
|
};
|
|
772
939
|
this.tests.push(test);
|
|
773
940
|
if (context.declaredIn)
|
|
774
941
|
this.declaredIn.set(test, context.declaredIn);
|
|
775
942
|
this.cut(context.scope, call);
|
|
776
|
-
|
|
777
|
-
|
|
943
|
+
// A function declared in the file and passed by name runs as the test body too.
|
|
944
|
+
const body = declaration.fn ?? declaration.hashed;
|
|
945
|
+
if (body) {
|
|
946
|
+
if (!declaration.fn)
|
|
947
|
+
this.walkedAtCall.add(body);
|
|
948
|
+
this.visitCallback(body, {
|
|
778
949
|
...context,
|
|
779
950
|
test,
|
|
780
951
|
hookScope: null,
|
|
952
|
+
hookName: null,
|
|
953
|
+
stepDepth: 0,
|
|
781
954
|
testContext: declaration.testContext,
|
|
782
955
|
guards: [],
|
|
783
956
|
helper: null,
|
|
@@ -787,7 +960,7 @@ class Walker {
|
|
|
787
960
|
declareSuite(call, declaration, context) {
|
|
788
961
|
if (context.quiet || this.nested(call, context))
|
|
789
962
|
return;
|
|
790
|
-
for (const item of declaration.problems)
|
|
963
|
+
for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
|
|
791
964
|
this.report(item);
|
|
792
965
|
const scope = {
|
|
793
966
|
kind: 'describe',
|
|
@@ -796,6 +969,9 @@ class Walker {
|
|
|
796
969
|
mode: declaration.mode,
|
|
797
970
|
isOnly: declaration.isOnly,
|
|
798
971
|
details: declaration.details,
|
|
972
|
+
hooks: noHooks(),
|
|
973
|
+
hookRun: {},
|
|
974
|
+
hookFixtures: [],
|
|
799
975
|
suite: null,
|
|
800
976
|
};
|
|
801
977
|
const suite = {
|
|
@@ -817,6 +993,8 @@ class Walker {
|
|
|
817
993
|
scope,
|
|
818
994
|
test: null,
|
|
819
995
|
hookScope: null,
|
|
996
|
+
hookName: null,
|
|
997
|
+
stepDepth: 0,
|
|
820
998
|
testContext: null,
|
|
821
999
|
guards: [],
|
|
822
1000
|
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) {
|
|
@@ -75,6 +75,8 @@ export declare function mayDeclareTests(name: string, links: Link[]): boolean;
|
|
|
75
75
|
/** A function declared in the file and passed by name, as in `test('fills an order', checkFill)`; null otherwise. */
|
|
76
76
|
export declare function namedFunction(file: FileContext, scopes: readonly Node[], node: Argument | undefined): FunctionNode | null;
|
|
77
77
|
export declare function findFunction(args: readonly Argument[], from?: number): FunctionNode | null;
|
|
78
|
+
/** A hook's function: written in the call, or declared in the file and passed by name, as in `beforeEach(signIn)`. */
|
|
79
|
+
export declare function hookFunction(file: FileContext, scopes: readonly Node[], args: readonly Argument[]): FunctionNode | null;
|
|
78
80
|
/** Reads a title. A title that cannot be worked out keeps its source text and gets a `dynamic-title` warning. */
|
|
79
81
|
export declare function readTitle(file: FileContext, scopes: readonly Node[], node: Argument, options: {
|
|
80
82
|
stripTags: boolean;
|
|
@@ -112,6 +114,10 @@ export interface Options {
|
|
|
112
114
|
export declare function readOptions(file: FileContext, scopes: readonly Node[], node: Argument): Options;
|
|
113
115
|
/** Warnings for the parts of an options object that could not be worked out, with what is missing as a result. */
|
|
114
116
|
export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
|
|
117
|
+
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
118
|
+
export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
|
|
119
|
+
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
120
|
+
export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
|
|
115
121
|
/** A warning for one option whose value could not be worked out. */
|
|
116
122
|
export declare function unresolvedOption(file: FileContext, name: string, node: Node, importedFrom: string | null, result: string): Problem;
|
|
117
123
|
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) {
|
|
@@ -166,6 +166,10 @@ export function findFunction(args, from = 0) {
|
|
|
166
166
|
}
|
|
167
167
|
return null;
|
|
168
168
|
}
|
|
169
|
+
/** A hook's function: written in the call, or declared in the file and passed by name, as in `beforeEach(signIn)`. */
|
|
170
|
+
export function hookFunction(file, scopes, args) {
|
|
171
|
+
return findFunction(args) ?? args.map((arg) => namedFunction(file, scopes, arg)).find((fn) => fn !== null) ?? null;
|
|
172
|
+
}
|
|
169
173
|
/** Reads a title. A title that cannot be worked out keeps its source text and gets a `dynamic-title` warning. */
|
|
170
174
|
export function readTitle(file, scopes, node, options) {
|
|
171
175
|
const expression = node.type === 'SpreadElement' ? null : node;
|
|
@@ -275,6 +279,27 @@ export function unresolvedOptions(file, options, result) {
|
|
|
275
279
|
return problem('unresolved-option', node, `Options object ${source}${origin(importedFrom)} could not be worked out; ${result}.`, source);
|
|
276
280
|
});
|
|
277
281
|
}
|
|
282
|
+
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
283
|
+
export function destructuredNames(fn, index) {
|
|
284
|
+
const parameter = index === null ? undefined : fn?.params[index];
|
|
285
|
+
if (parameter?.type !== 'ObjectPattern')
|
|
286
|
+
return [];
|
|
287
|
+
const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
|
|
288
|
+
return names.filter((name) => name !== null);
|
|
289
|
+
}
|
|
290
|
+
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
291
|
+
export function readCount(file, name, option, problems) {
|
|
292
|
+
const { node, resolved } = option;
|
|
293
|
+
if (!resolved.ok) {
|
|
294
|
+
problems.push(unresolvedOption(file, name, node, resolved.importedFrom, 'it is not applied'));
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
if (Number.isInteger(resolved.value) && resolved.value >= 0)
|
|
298
|
+
return resolved.value;
|
|
299
|
+
const source = snippet(text(file, node));
|
|
300
|
+
problems.push(problem('unresolved-option', node, `Option ${name}: ${source} is not a whole number; it is not applied.`, source));
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
278
303
|
/** A warning for one option whose value could not be worked out. */
|
|
279
304
|
export function unresolvedOption(file, name, node, importedFrom, result) {
|
|
280
305
|
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;
|