test-inventory 0.8.0 → 0.9.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +16 -11
  3. package/dist/case-titles.d.ts +9 -0
  4. package/dist/case-titles.js +82 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/config-dependencies.js +19 -0
  8. package/dist/config-runs.d.ts +95 -3
  9. package/dist/config-runs.js +752 -16
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.js +139 -19
  12. package/dist/diagnostics.js +14 -1
  13. package/dist/extract.d.ts +2 -0
  14. package/dist/extract.js +36 -10
  15. package/dist/files.js +2 -1
  16. package/dist/filter.js +1 -0
  17. package/dist/framework-version.d.ts +4 -2
  18. package/dist/framework-version.js +170 -15
  19. package/dist/frameworks/bun.d.ts +42 -0
  20. package/dist/frameworks/bun.js +272 -0
  21. package/dist/frameworks/common.d.ts +3 -1
  22. package/dist/frameworks/common.js +11 -9
  23. package/dist/frameworks/cypress.d.ts +31 -0
  24. package/dist/frameworks/cypress.js +70 -0
  25. package/dist/frameworks/jasmine.d.ts +28 -0
  26. package/dist/frameworks/jasmine.js +218 -0
  27. package/dist/frameworks/jest.js +1 -1
  28. package/dist/frameworks/mocha.d.ts +50 -0
  29. package/dist/frameworks/mocha.js +306 -0
  30. package/dist/frameworks/node.d.ts +30 -0
  31. package/dist/frameworks/node.js +268 -0
  32. package/dist/frameworks/playwright.d.ts +13 -0
  33. package/dist/frameworks/playwright.js +57 -0
  34. package/dist/frameworks/test-functions.d.ts +26 -1
  35. package/dist/frameworks/test-functions.js +64 -22
  36. package/dist/frameworks/vitest.d.ts +9 -1
  37. package/dist/frameworks/vitest.js +36 -5
  38. package/dist/frameworks/webdriverio.d.ts +27 -0
  39. package/dist/frameworks/webdriverio.js +37 -0
  40. package/dist/model.d.ts +4 -0
  41. package/dist/node-scripts.d.ts +6 -0
  42. package/dist/node-scripts.js +85 -0
  43. package/dist/options.d.ts +13 -4
  44. package/dist/options.js +22 -3
  45. package/dist/records.d.ts +8 -2
  46. package/dist/records.js +50 -13
  47. package/dist/resolve.d.ts +21 -4
  48. package/dist/resolve.js +52 -16
  49. package/dist/scan-file.d.ts +8 -0
  50. package/dist/scan-file.js +16 -2
  51. package/dist/scan.js +42 -7
  52. package/dist/toml.d.ts +13 -0
  53. package/dist/toml.js +172 -0
  54. package/dist/types.d.ts +19 -9
  55. package/dist/values.d.ts +5 -0
  56. package/dist/values.js +13 -0
  57. package/dist/yaml.d.ts +12 -0
  58. package/dist/yaml.js +148 -0
  59. package/package.json +7 -6
  60. package/schema/v1.json +26 -6
@@ -0,0 +1,306 @@
1
+ import { isFunctionNode, propertyName, unwrap } from "../ast.js";
2
+ import { snippet } from "../source.js";
3
+ import { requiredModule } from "./bindings.js";
4
+ import { atLeast, hookFunction, namedFunction, problem, readChain, readCount, readTitle, refused, text, } from "./common.js";
5
+ import { SuiteAdapter } from "./test-functions.js";
6
+ const SOURCE = 'mocha';
7
+ const MISMATCHED = new Set(['@playwright/test', 'playwright/test', 'vitest', '@jest/globals']);
8
+ const MODIFIERS = new Set(['only', 'skip']);
9
+ /** Which of `test` and `describe` each global is, and the modifier it carries, by interface. */
10
+ const STARTS = {
11
+ bdd: [
12
+ ['describe', 'describe', []],
13
+ ['context', 'describe', []],
14
+ ['xdescribe', 'describe', ['skip']],
15
+ ['xcontext', 'describe', ['skip']],
16
+ ['it', 'test', []],
17
+ ['specify', 'test', []],
18
+ ['xit', 'test', ['skip']],
19
+ ['xspecify', 'test', ['skip']],
20
+ ],
21
+ tdd: [
22
+ ['suite', 'describe', []],
23
+ ['test', 'test', []],
24
+ ],
25
+ };
26
+ const HOOKS = {
27
+ bdd: [
28
+ ['before', 'beforeAll'],
29
+ ['after', 'afterAll'],
30
+ ['beforeEach', 'beforeEach'],
31
+ ['afterEach', 'afterEach'],
32
+ ],
33
+ tdd: [
34
+ ['suiteSetup', 'beforeAll'],
35
+ ['suiteTeardown', 'afterAll'],
36
+ ['setup', 'beforeEach'],
37
+ ['teardown', 'afterEach'],
38
+ ],
39
+ };
40
+ export function dialect(ui) {
41
+ return {
42
+ name: 'Mocha',
43
+ source: SOURCE,
44
+ mismatched: MISMATCHED,
45
+ starts: new Map(STARTS[ui].map(([name, kind, links]) => [name, { kind, links }])),
46
+ hooks: new Map(HOOKS[ui]),
47
+ derivations: new Set(),
48
+ modifiers: MODIFIERS,
49
+ carriesKinds: false,
50
+ isModule: (expression) => requiredModule(expression) === SOURCE,
51
+ // `import mocha from 'mocha'` gives the module's exports, `describe` and `it` among them.
52
+ defaultImport: null,
53
+ required: null,
54
+ };
55
+ }
56
+ /** Mocha's `ms` strings for a timeout, as `'2s'`; a bare number is milliseconds. */
57
+ const DURATION = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/;
58
+ const UNITS = {
59
+ ms: 1,
60
+ s: 1000,
61
+ m: 60_000,
62
+ h: 3_600_000,
63
+ d: 86_400_000,
64
+ w: 604_800_000,
65
+ y: 31_557_600_000,
66
+ };
67
+ export class MochaAdapter extends SuiteAdapter {
68
+ /**
69
+ * Whether `this.timeout()` and `this.retries()` set the tests' settings, as in Mocha. Cypress reads neither, and its
70
+ * `retries()` throws.
71
+ */
72
+ readsContextSettings = true;
73
+ /** What the runner does with a call it refuses while it loads the file, as the end of a sentence. */
74
+ refusedEffect = 'Mocha stops before it runs any test';
75
+ /** Whether a number after a test's function is a retry count, as WebdriverIO reads it. */
76
+ trailingRetries = false;
77
+ constructor(parsed, file, readModule, ui = 'bdd', runner = dialect(ui)) {
78
+ super(parsed, file, readModule, runner, MODIFIERS);
79
+ }
80
+ /** A declaration's function, and the options written before it; Mocha takes none. */
81
+ splitArguments(args) {
82
+ return { body: args.at(1), options: null };
83
+ }
84
+ classify(call, context) {
85
+ return (this.contextCall(call, context) ??
86
+ this.chainedSetting(call, context) ??
87
+ this.hookCall(call, context) ??
88
+ this.chainCall(call, context));
89
+ }
90
+ isAliasLink(_kind, link) {
91
+ return MODIFIERS.has(link.name);
92
+ }
93
+ hook(call, name, context) {
94
+ const fn = hookFunction(this.file, context.scopes, call.arguments);
95
+ // Mocha refuses `this.skip()` in an `after` hook, so the tests stay as they ran.
96
+ const testContext = name === 'afterAll' ? null : ownThis(fn);
97
+ return { kind: 'hook', name, fn, testContext, fixtures: [], takesAllFixtures: false };
98
+ }
99
+ /**
100
+ * `this.timeout(n)`, `this.retries(n)` and `this.skip()` where `this` is Mocha's context: in a describe written as a
101
+ * `function`, they set its tests' timeout and retries; in a test, its own; `this.skip()` in a test or hook skips the
102
+ * tests it runs for. In a hook, `this.timeout()` sets the hook's own timeout, which no test record holds.
103
+ */
104
+ contextCall(call, context) {
105
+ const callee = unwrap(call.callee);
106
+ if (callee.type !== 'MemberExpression' || unwrap(callee.object).type !== 'ThisExpression')
107
+ return null;
108
+ const name = propertyName(callee);
109
+ // `this` is Mocha's only directly in the test's, hook's or describe's own function, not in a function inside it.
110
+ const own = innermostFunction(context.scopes);
111
+ const inTestOrHook = context.inBody && own !== null && own === context.testContext;
112
+ const inDescribe = !context.inBody && own !== null && own === context.passedDown;
113
+ if (!inTestOrHook && !inDescribe)
114
+ return null;
115
+ if (name === 'skip') {
116
+ if (!inTestOrHook)
117
+ return { kind: 'other' };
118
+ return {
119
+ kind: 'modifier',
120
+ state: 'skip',
121
+ condition: { applies: 'always', text: null },
122
+ reason: null,
123
+ hasDynamicReason: false,
124
+ problems: [],
125
+ };
126
+ }
127
+ if (name !== 'timeout' && name !== 'retries')
128
+ return null;
129
+ if (!this.readsContextSettings) {
130
+ // In a test or hook, the throw fails that test or hook only, when it runs.
131
+ if (name === 'timeout' || inTestOrHook)
132
+ return { kind: 'other' };
133
+ const source = snippet(text(this.file, call));
134
+ const message = `${source} throws in ${this.dialect.name}, which takes retries in a describe's config, as describe(title, { retries: 2 }, fn), so ${this.refusedEffect}.`;
135
+ return { kind: 'invalid', problem: problem('describe-throws', call, message, source) };
136
+ }
137
+ const argument = call.arguments.at(0);
138
+ // Without an argument, `this.timeout()` only reads the timeout.
139
+ if (!argument || (context.inBody && !context.inTest))
140
+ return { kind: 'other' };
141
+ const problems = [];
142
+ const settings = this.setting(name, argument, context, problems) ?? {};
143
+ return { kind: 'run', settings, argument: snippet(text(this.file, argument)), problems };
144
+ }
145
+ /**
146
+ * `it(title, fn).timeout(n)` and `.retries(n)`, which Mocha's `it` and `describe` return their test or suite for: the
147
+ * declaration with the setting. A describe's tests are added before the call, and take its retries when they are
148
+ * added, so `describe(...).retries(n)` reaches none of them; its timeout reaches them from Mocha 12, which reads it as
149
+ * each test runs (checked with 10.8.2, 11.7.5 and 12.0.3).
150
+ */
151
+ chainedSetting(call, context) {
152
+ const callee = unwrap(call.callee);
153
+ if (callee.type !== 'MemberExpression')
154
+ return null;
155
+ const name = propertyName(callee);
156
+ if (name !== 'timeout' && name !== 'retries')
157
+ return null;
158
+ if (!this.readsContextSettings && name === 'timeout')
159
+ return null;
160
+ const target = unwrap(callee.object);
161
+ if (target.type !== 'CallExpression')
162
+ return null;
163
+ const declared = this.classify(target, context);
164
+ if (declared?.kind !== 'test' && declared?.kind !== 'describe')
165
+ return null;
166
+ if (!this.readsContextSettings) {
167
+ if (context.inBody)
168
+ return declared;
169
+ const source = snippet(text(this.file, callee));
170
+ const message = `${source}() throws in ${this.dialect.name}, which takes retries in the config before the function, as it(title, { retries: 2 }, fn), so ${this.refusedEffect}.`;
171
+ return { kind: 'invalid', problem: problem('invalid-chain', call, message, source), declaration: declared };
172
+ }
173
+ if (declared.kind === 'describe' && (name === 'retries' || !atLeast(this.file.version, 12, 0)))
174
+ return declared;
175
+ const argument = call.arguments.at(0);
176
+ const settings = argument ? this.setting(name, argument, context, declared.problems) : null;
177
+ if (settings === null)
178
+ return declared;
179
+ return { ...declared, details: { ...declared.details, run: { ...declared.details.run, ...settings } } };
180
+ }
181
+ /** A timeout or a retry count from its argument; null, with a warning, when it can't be worked out. */
182
+ setting(name, argument, context, problems) {
183
+ const resolved = argument.type === 'SpreadElement' ? null : this.file.values.resolve(argument, context.scopes);
184
+ const value = resolved?.ok ? durationOf(resolved.value, name) : null;
185
+ if (value === null) {
186
+ const source = snippet(text(this.file, argument));
187
+ const message = `The ${name} ${source} could not be worked out; it is not applied.`;
188
+ problems.push(problem('unresolved-option', argument, message, source));
189
+ return null;
190
+ }
191
+ return name === 'timeout' ? { timeout: value } : { retry: { count: value, delay: null, condition: null } };
192
+ }
193
+ chainCall(call, context) {
194
+ const chain = readChain(call.callee);
195
+ const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
196
+ if (!resolved)
197
+ return null;
198
+ const { kind, links } = resolved;
199
+ const valid = links.length <= 1 && links.every((link) => MODIFIERS.has(link.name) && !link.args && !link.template);
200
+ if (valid)
201
+ return this.declaration(call, kind, links, context);
202
+ const callee = snippet(text(this.file, call.callee));
203
+ const refusal = problem('invalid-chain', call, `${callee} is not part of ${this.dialect.name}'s API; the call throws, and ${this.refusedEffect}.`, callee);
204
+ return refused(call, refusal, context, () => this.declaration(call, kind, [], context));
205
+ }
206
+ declaration(call, kind, links, context) {
207
+ const args = call.arguments;
208
+ if (args.length === 0) {
209
+ const callee = snippet(text(this.file, call.callee));
210
+ const message = `${callee}() has no title, so ${this.refusedEffect}.`;
211
+ return { kind: 'invalid', problem: problem('test-without-body', call, message, callee) };
212
+ }
213
+ const flags = new Set(links.map((link) => link.name));
214
+ const problems = [];
215
+ const { title, problem: titleProblem } = readTitle(this.file, context.scopes, args[0], {
216
+ stripTags: false,
217
+ functionNames: false,
218
+ });
219
+ if (titleProblem)
220
+ problems.push(titleProblem);
221
+ const { body, options } = this.splitArguments(args);
222
+ const fn = isFunctionNode(body) ? body : null;
223
+ const hashed = fn ?? namedFunction(this.file, context.scopes, body);
224
+ const line = this.file.source.line(call.start);
225
+ // A test without a function is pending, which Mocha reports as skipped.
226
+ const isSkipped = flags.has('skip') || (kind === 'test' && body === undefined);
227
+ const base = {
228
+ title,
229
+ states: isSkipped
230
+ ? [{ state: 'skip', condition: null, reason: null, hasDynamicReason: false, line }]
231
+ : [],
232
+ isOnly: flags.has('only'),
233
+ mode: null,
234
+ details: { tags: [], annotations: [], locks: [], meta: null, run: {} },
235
+ fn,
236
+ hashed,
237
+ isParameterized: false,
238
+ caseCount: null,
239
+ cases: null,
240
+ problems,
241
+ };
242
+ if (kind === 'test') {
243
+ const test = {
244
+ kind: 'test',
245
+ ...base,
246
+ testContext: ownThis(hashed),
247
+ fixtures: [],
248
+ takesAllFixtures: false,
249
+ usesBench: false,
250
+ };
251
+ // WebdriverIO retries the function that many times inside each of Mocha's attempts, so 0 leaves Mocha's retries.
252
+ // `it.only` and `it.skip` pass Mocha only the title and function, so the count is dropped.
253
+ const retries = this.trailingRetries && flags.size === 0 && args.length >= 3 ? args.at(-1) : undefined;
254
+ if (retries && retries.type !== 'SpreadElement') {
255
+ const option = { node: retries, resolved: this.file.values.resolve(retries, context.scopes) };
256
+ const count = readCount(this.file, 'retries', option, problems);
257
+ if (count)
258
+ test.details.run = { retry: { count, delay: null, condition: null } };
259
+ }
260
+ if (options)
261
+ this.withOptions?.(test, options, context);
262
+ return test;
263
+ }
264
+ const declared = { kind: 'describe', ...base, isShuffled: false, passesDown: ownThis(hashed) };
265
+ if (options)
266
+ this.withOptions?.(declared, options, context);
267
+ if (body !== undefined || flags.has('skip'))
268
+ return declared;
269
+ const callee = snippet(text(this.file, call.callee));
270
+ const refusal = problem('test-without-body', call, `${callee}() has no function, so ${this.refusedEffect}.`, callee);
271
+ return refused(call, refusal, context, () => declared);
272
+ }
273
+ }
274
+ /**
275
+ * A test's, hook's or describe's function written with `function`, whose `this` is Mocha's context while it runs; null
276
+ * for an arrow function, whose `this` is whatever is around it.
277
+ */
278
+ function ownThis(fn) {
279
+ return fn && fn.type !== 'ArrowFunctionExpression' ? fn : null;
280
+ }
281
+ /** The innermost function around a call that has its own `this`: one not written as an arrow. */
282
+ function innermostFunction(scopes) {
283
+ return (scopes.findLast((scope) => scope.type === 'FunctionExpression' || scope.type === 'FunctionDeclaration') ?? null);
284
+ }
285
+ /** The largest timeout Mocha keeps; it takes this one and any larger as no timeout, 0. */
286
+ const LONGEST = 2 ** 31 - 1;
287
+ /** A timeout in milliseconds or a retry count as Mocha reads it; null for a value it doesn't take. */
288
+ export function durationOf(value, name) {
289
+ if (name === 'retries')
290
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
291
+ let ms = null;
292
+ if (typeof value === 'number') {
293
+ ms = value;
294
+ }
295
+ else if (typeof value === 'string') {
296
+ const match = DURATION.exec(value.trim());
297
+ // An optional group that matched nothing is undefined, though the type says it is a string.
298
+ if (match)
299
+ ms = Number(match[1]) * UNITS[match[2] ?? 'ms'];
300
+ }
301
+ if (ms === null || Number.isNaN(ms))
302
+ return null;
303
+ if (ms <= 0 || ms >= LONGEST)
304
+ return 0;
305
+ return Number.isInteger(ms) ? ms : null;
306
+ }
@@ -0,0 +1,30 @@
1
+ import type { CallExpression } from 'oxc-parser';
2
+ import type { ApiCall, CallContext, ModuleReader } from '../model.ts';
3
+ import type { ParsedFile } from '../parse.ts';
4
+ import type { HookCounts } from '../types.ts';
5
+ import { type FileContext, type Link } from './common.ts';
6
+ import { SuiteAdapter, type Kind } from './test-functions.ts';
7
+ /**
8
+ * Node.js's test runner, `node:test`. Its functions are imported, never globals; the module itself is `test`. A test
9
+ * takes `(name, options, fn)`, each part optional, and a test or `test()` called in a test's body is a subtest of it.
10
+ */
11
+ export declare class NodeAdapter extends SuiteAdapter {
12
+ readonly nestsTests = true;
13
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
14
+ classify(call: CallExpression, context: CallContext): ApiCall | null;
15
+ protected isAliasLink(_kind: Kind, link: Link): boolean;
16
+ protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
17
+ /**
18
+ * `t.skip(reason)` and `t.todo(reason)` on the test's context, which mark the test without stopping it, and
19
+ * `t.test()`, a subtest.
20
+ */
21
+ private contextCall;
22
+ private chainCall;
23
+ private declaration;
24
+ /**
25
+ * The options object: `skip` and `todo`, each `true` or a reason, `only`, `timeout`, `concurrency`, a number above 1
26
+ * or `true` running the tests inside in parallel, from Node.js 24.14 and 25.5 `expectFailure`, whose string is its
27
+ * label from 24.15 and 25.8, and from 24.19 and 26.2 `tags`.
28
+ */
29
+ private withOptions;
30
+ }
@@ -0,0 +1,268 @@
1
+ import { isFunctionNode, propertyName, unwrap } from "../ast.js";
2
+ import { snippet } from "../source.js";
3
+ import { requiredModule } from "./bindings.js";
4
+ import { hookFunction, namedFunction, problem, readChain, readCount, readOptions, readReason, readStrings, readTitle, refused, text, unresolvedOptions, } from "./common.js";
5
+ import { SuiteAdapter } from "./test-functions.js";
6
+ const MODIFIERS = new Set(['skip', 'todo', 'only', 'expectFailure']);
7
+ const PRECEDENCE = ['skip', 'todo', 'expectedToFail'];
8
+ const TEST = { kind: 'test', links: [] };
9
+ const NODE = {
10
+ name: 'node:test',
11
+ source: 'node:test',
12
+ mismatched: new Set(['@playwright/test', 'playwright/test', 'vitest', '@jest/globals', 'mocha']),
13
+ starts: new Map([
14
+ ['test', TEST],
15
+ ['it', TEST],
16
+ ['describe', { kind: 'describe', links: [] }],
17
+ ['suite', { kind: 'describe', links: [] }],
18
+ // `test`'s own members, which node:test also exports by name.
19
+ ...['skip', 'todo', 'only', 'expectFailure'].map((name) => [
20
+ name,
21
+ { kind: 'test', links: [name] },
22
+ ]),
23
+ ]),
24
+ hooks: new Map([
25
+ ['before', 'beforeAll'],
26
+ ['after', 'afterAll'],
27
+ ['beforeEach', 'beforeEach'],
28
+ ['afterEach', 'afterEach'],
29
+ ]),
30
+ derivations: new Set(),
31
+ modifiers: MODIFIERS,
32
+ carriesKinds: false,
33
+ isModule: (expression) => requiredModule(expression) === 'node:test',
34
+ defaultImport: null,
35
+ required: null,
36
+ callable: TEST,
37
+ // Every other export of node:test on Node.js 26; the hooks are read as hooks.
38
+ others: new Set([
39
+ 'after',
40
+ 'afterEach',
41
+ 'assert',
42
+ 'before',
43
+ 'beforeEach',
44
+ 'default',
45
+ 'getTestContext',
46
+ 'mock',
47
+ 'run',
48
+ 'snapshot',
49
+ ]),
50
+ importOnly: true,
51
+ };
52
+ /**
53
+ * Node.js's test runner, `node:test`. Its functions are imported, never globals; the module itself is `test`. A test
54
+ * takes `(name, options, fn)`, each part optional, and a test or `test()` called in a test's body is a subtest of it.
55
+ */
56
+ export class NodeAdapter extends SuiteAdapter {
57
+ nestsTests = true;
58
+ constructor(parsed, file, readModule) {
59
+ super(parsed, file, readModule, NODE, []);
60
+ }
61
+ classify(call, context) {
62
+ return this.contextCall(call, context) ?? this.hookCall(call, context) ?? this.chainCall(call, context);
63
+ }
64
+ isAliasLink(_kind, link) {
65
+ return MODIFIERS.has(link.name);
66
+ }
67
+ hook(call, name, context) {
68
+ const fn = hookFunction(this.file, context.scopes, call.arguments);
69
+ return { kind: 'hook', name, fn, testContext: null, fixtures: [], takesAllFixtures: false };
70
+ }
71
+ /**
72
+ * `t.skip(reason)` and `t.todo(reason)` on the test's context, which mark the test without stopping it, and
73
+ * `t.test()`, a subtest.
74
+ */
75
+ contextCall(call, context) {
76
+ const callee = unwrap(call.callee);
77
+ if (callee.type !== 'MemberExpression' || context.testContext === null || !context.inTest)
78
+ return null;
79
+ const object = unwrap(callee.object);
80
+ if (object.type !== 'Identifier' || object.name !== context.testContext)
81
+ return null;
82
+ const name = propertyName(callee);
83
+ if (name === 'test')
84
+ return this.declaration(call, 'test', [], context);
85
+ if (name !== 'skip' && name !== 'todo')
86
+ return null;
87
+ const reason = readReason(this.file, context.scopes, call.arguments.at(0));
88
+ return {
89
+ kind: 'modifier',
90
+ state: name,
91
+ condition: { applies: 'always', text: null },
92
+ reason: reason.reason,
93
+ hasDynamicReason: reason.isDynamic,
94
+ problems: reason.problem ? [reason.problem] : [],
95
+ };
96
+ }
97
+ chainCall(call, context) {
98
+ const chain = readChain(call.callee);
99
+ const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
100
+ if (!resolved)
101
+ return null;
102
+ const { kind, links } = resolved;
103
+ const valid = links.length <= 1 &&
104
+ links.every((link) => MODIFIERS.has(link.name) &&
105
+ !link.args &&
106
+ !link.template &&
107
+ (link.name !== 'expectFailure' || expectsFailure(this.file.version)));
108
+ if (valid)
109
+ return this.declaration(call, kind, links, context);
110
+ const callee = snippet(text(this.file, call.callee));
111
+ const message = `${callee} is not part of node:test's API; the call throws, and Node.js fails the file and runs none of its tests.`;
112
+ return refused(call, problem('invalid-chain', call, message, callee), context, () => this.declaration(call, kind, [], context));
113
+ }
114
+ declaration(call, kind, links, context) {
115
+ const { name, options, body } = splitArguments(call.arguments);
116
+ const problems = [];
117
+ const fn = isFunctionNode(body) ? body : null;
118
+ let title = { text: '<anonymous>', withoutTags: '<anonymous>', isDynamic: false };
119
+ if (name) {
120
+ const read = readTitle(this.file, context.scopes, name, { stripTags: false, functionNames: false });
121
+ title = read.title;
122
+ if (read.problem)
123
+ problems.push(read.problem);
124
+ }
125
+ else if (fn?.id) {
126
+ // Without a name, Node.js names the test after its function.
127
+ title = { text: fn.id.name, withoutTags: fn.id.name, isDynamic: false };
128
+ }
129
+ const line = this.file.source.line(call.start);
130
+ const states = links
131
+ .filter((link) => link.name !== 'only')
132
+ .map((link) => ({
133
+ state: link.name === 'expectFailure' ? 'expectedToFail' : link.name,
134
+ condition: null,
135
+ reason: null,
136
+ hasDynamicReason: false,
137
+ line,
138
+ }));
139
+ const base = {
140
+ title,
141
+ states,
142
+ isOnly: links.some((link) => link.name === 'only'),
143
+ mode: null,
144
+ details: { tags: [], annotations: [], locks: [], meta: null, run: {} },
145
+ fn,
146
+ hashed: fn ?? namedFunction(this.file, context.scopes, body),
147
+ isParameterized: false,
148
+ caseCount: null,
149
+ cases: null,
150
+ problems,
151
+ };
152
+ if (options)
153
+ this.withOptions(base, options, line, context);
154
+ // `skip` wins over `todo`, and both over `expectFailure`, however they are written.
155
+ base.states.sort((a, b) => PRECEDENCE.indexOf(a.state) - PRECEDENCE.indexOf(b.state));
156
+ if (kind === 'describe')
157
+ return { kind: 'describe', ...base, isShuffled: false, passesDown: null };
158
+ return {
159
+ kind: 'test',
160
+ ...base,
161
+ testContext: contextName(fn),
162
+ fixtures: [],
163
+ takesAllFixtures: false,
164
+ usesBench: false,
165
+ };
166
+ }
167
+ /**
168
+ * The options object: `skip` and `todo`, each `true` or a reason, `only`, `timeout`, `concurrency`, a number above 1
169
+ * or `true` running the tests inside in parallel, from Node.js 24.14 and 25.5 `expectFailure`, whose string is its
170
+ * label from 24.15 and 25.8, and from 24.19 and 26.2 `tags`.
171
+ */
172
+ withOptions(declared, node, line, context) {
173
+ const options = readOptions(this.file, context.scopes, node);
174
+ const { problems } = declared;
175
+ problems.push(...unresolvedOptions(this.file, options, 'its states, timeout and tags are missing'));
176
+ const states = [
177
+ ['skip', 'skip'],
178
+ ['todo', 'todo'],
179
+ ];
180
+ if (expectsFailure(this.file.version))
181
+ states.push(['expectedToFail', 'expectFailure']);
182
+ for (const [state, key] of states) {
183
+ const option = options.values.get(key);
184
+ if (!option)
185
+ continue;
186
+ const setting = stateFrom(this.file, state, option, line, state !== 'expectedToFail' || labelsFailure(this.file.version));
187
+ if (setting)
188
+ declared.states.push(setting);
189
+ }
190
+ const only = options.values.get('only');
191
+ if (only?.resolved.ok && only.resolved.value)
192
+ declared.isOnly = true;
193
+ const timeout = options.values.get('timeout');
194
+ if (timeout) {
195
+ const value = readCount(this.file, 'timeout', timeout, problems);
196
+ if (value !== null)
197
+ declared.details.run.timeout = value;
198
+ }
199
+ const concurrency = options.values.get('concurrency');
200
+ if (concurrency?.resolved.ok) {
201
+ const { value } = concurrency.resolved;
202
+ declared.mode = value === true || (typeof value === 'number' && value > 1) ? 'parallel' : 'default';
203
+ }
204
+ const tags = options.values.get('tags');
205
+ if (tags && readsTags(this.file.version)) {
206
+ declared.details.tags.push(...readStrings(this.file, context.scopes, tags).values);
207
+ }
208
+ }
209
+ }
210
+ /** Whether a version is at least the given release of its line, or of a later line. */
211
+ function since(version, releases, from) {
212
+ if (version === null)
213
+ return true;
214
+ const [major, minor] = version;
215
+ return major >= from || (major in releases && minor >= releases[major]);
216
+ }
217
+ /** `expectFailure`: from Node.js 24.14 and 25.5. */
218
+ function expectsFailure(version) {
219
+ return since(version, { 24: 14, 25: 5 }, 26);
220
+ }
221
+ /** A string `expectFailure` as the failure's label: from 24.15 and 25.8. */
222
+ function labelsFailure(version) {
223
+ return since(version, { 24: 15, 25: 8 }, 26);
224
+ }
225
+ /** `tags`: from 24.19 and 26.2; Node.js 25 doesn't have them. */
226
+ function readsTags(version) {
227
+ return since(version, { 24: 19, 26: 2 }, 27);
228
+ }
229
+ /**
230
+ * A state an option sets: any value but `false` and `undefined` sets it, as Node.js only checks for those two, with a
231
+ * non-empty string as its reason; a value the scan can't work out sets it under its source text.
232
+ */
233
+ function stateFrom(file, state, option, line, takesReason) {
234
+ const source = snippet(text(file, option.node));
235
+ if (!option.resolved.ok) {
236
+ return { state, condition: source, reason: null, hasDynamicReason: false, line };
237
+ }
238
+ const { value } = option.resolved;
239
+ if (value === undefined || value === false)
240
+ return null;
241
+ const reason = typeof value === 'string' && value !== '' && takesReason ? value : null;
242
+ return { state, condition: null, reason, hasDynamicReason: false, line };
243
+ }
244
+ /**
245
+ * The name, options and function of a call, as Node.js reads `test([name][, options][, fn])`: a function first is the
246
+ * test's function, an object first its options.
247
+ */
248
+ function splitArguments(args) {
249
+ const [first, second, third] = args;
250
+ if (first && isFunctionLike(first))
251
+ return { name: undefined, options: undefined, body: first };
252
+ if (first && isObjectLike(first))
253
+ return { name: undefined, options: first, body: second };
254
+ if (second && isFunctionLike(second))
255
+ return { name: first, options: undefined, body: second };
256
+ return { name: first, options: second, body: third };
257
+ }
258
+ function isFunctionLike(node) {
259
+ return node.type !== 'SpreadElement' && isFunctionNode(unwrap(node));
260
+ }
261
+ function isObjectLike(node) {
262
+ return node.type !== 'SpreadElement' && unwrap(node).type === 'ObjectExpression';
263
+ }
264
+ /** The name the test's function gives its context, `t` in `(t) => {}`; null without one. */
265
+ function contextName(fn) {
266
+ const param = fn?.params.at(0);
267
+ return param?.type === 'Identifier' ? param.name : null;
268
+ }
@@ -12,6 +12,7 @@ export declare class PlaywrightAdapter implements Adapter {
12
12
  private readonly imports;
13
13
  private readonly followed;
14
14
  private readonly reported;
15
+ private readonly refusedRests;
15
16
  /** Variables and names of functions read as test objects on trust, each reported once. */
16
17
  private readonly assumed;
17
18
  private readonly aliases;
@@ -55,6 +56,18 @@ export declare class PlaywrightAdapter implements Adapter {
55
56
  /** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
56
57
  private invalid;
57
58
  private testCall;
59
+ /**
60
+ * Playwright reads which fixtures a function takes from its source, and refuses a rest property among them, as
61
+ * `({ page, ...fixtures })`, failing the whole run. A nested one, as `({ page: { mouse, ...rest } })`, is allowed.
62
+ */
63
+ private checkFixtureArguments;
64
+ /** The fixture functions of `test.extend({ ... })`, each given as a function or as `[function, options]`. */
65
+ private checkExtendFixtures;
66
+ /**
67
+ * An object of fixtures, written in place or held in a constant, with the objects it spreads; `seen` ends a constant
68
+ * that spreads itself, which throws at run time.
69
+ */
70
+ private checkFixtureObject;
58
71
  /** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
59
72
  private step;
60
73
  private modifier;