eslint-plugin-test-governance 0.3.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/README.md +166 -65
  3. package/dist/index.d.ts +11 -9
  4. package/dist/index.js +16 -6
  5. package/dist/rules/marker-matches-state.d.ts +0 -1
  6. package/dist/rules/marker-matches-state.js +4 -4
  7. package/dist/rules/no-conflicting-states.d.ts +0 -1
  8. package/dist/rules/no-conflicting-states.js +8 -16
  9. package/dist/rules/no-orphaned-marker.d.ts +0 -1
  10. package/dist/rules/no-orphaned-marker.js +12 -7
  11. package/dist/rules/require-ticket-in-comments.d.ts +0 -1
  12. package/dist/rules/require-ticket-in-comments.js +8 -13
  13. package/dist/rules/require-ticket.d.ts +0 -1
  14. package/dist/rules/require-ticket.js +16 -8
  15. package/dist/utils/analyze.d.ts +15 -12
  16. package/dist/utils/analyze.js +358 -167
  17. package/dist/utils/constants.d.ts +5 -3
  18. package/dist/utils/constants.js +4 -3
  19. package/dist/utils/create-rule.d.ts +7 -8
  20. package/dist/utils/create-rule.js +3 -9
  21. package/dist/utils/distance.d.ts +0 -1
  22. package/dist/utils/distance.js +0 -1
  23. package/dist/utils/errors.d.ts +0 -1
  24. package/dist/utils/errors.js +0 -1
  25. package/dist/utils/fix.d.ts +0 -3
  26. package/dist/utils/fix.js +0 -8
  27. package/dist/utils/frameworks.d.ts +59 -0
  28. package/dist/utils/frameworks.js +102 -0
  29. package/dist/utils/options.d.ts +24 -9
  30. package/dist/utils/options.js +8 -4
  31. package/dist/utils/schema.d.ts +0 -1
  32. package/dist/utils/schema.js +2 -2
  33. package/dist/utils/tickets.d.ts +0 -1
  34. package/dist/utils/tickets.js +3 -3
  35. package/dist/utils/validate.d.ts +0 -1
  36. package/dist/utils/validate.js +2 -2
  37. package/docs/rules/marker-matches-state.md +18 -10
  38. package/docs/rules/no-conflicting-states.md +14 -8
  39. package/docs/rules/no-orphaned-marker.md +31 -17
  40. package/docs/rules/require-ticket-in-comments.md +26 -22
  41. package/docs/rules/require-ticket.md +107 -47
  42. package/package.json +5 -4
@@ -1,19 +1,27 @@
1
- const PLAYWRIGHT_MODULES = new Set(['@playwright/test', 'playwright/test']);
2
- /** Other test runners. A name imported from one of these is never Playwright's `test`. */
3
- const OTHER_FRAMEWORKS = new Set(['vitest', '@jest/globals', 'node:test', 'bun:test', 'mocha', 'ava', 'tap', 'uvu']);
4
- /** `@playwright/test` and the component-testing packages such as `@playwright/experimental-ct-react`. */
5
- function isPlaywrightModule(source) {
6
- return PLAYWRIGHT_MODULES.has(source) || source.startsWith('@playwright/experimental-ct-');
7
- }
8
- const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'fail', 'slow']);
9
- const DESCRIBE_MODIFIERS = new Set(['only', 'skip', 'fixme', 'serial', 'parallel']);
10
- const RUNTIME_MODIFIERS = new Set(['skip', 'fixme', 'fail', 'slow']);
11
- const SKIPPING_MODIFIERS = new Set(['skip', 'fixme']);
1
+ import { isOtherRunner, RUNTIME_MODIFIERS } from "./frameworks.js";
2
+ const TEST = { kind: 'test', modifiers: [] };
3
+ /** Modifiers that keep a test from running. */
4
+ const SKIPPING_MODIFIERS = new Set(['skip', 'fixme', 'todo']);
5
+ /** What a step's `TestStepInfo` can do at runtime: `step.skip()`. */
6
+ const STEP_INFO_METHODS = new Set(['skip']);
12
7
  const MARKER_LINE_RE = /^\s*\*?\s*([A-Za-z][\w-]*)\s*:(.*)$/;
13
8
  const TAG_RE = /(?<![\w@])@[\w-]+/g;
14
9
  export function isStateMarker(marker) {
15
10
  return marker.state !== undefined;
16
11
  }
12
+ /** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
13
+ function infoNames(param, methods, step) {
14
+ if (param?.type === 'Identifier')
15
+ return [{ name: param.name, methods, step }];
16
+ if (param?.type !== 'ObjectPattern')
17
+ return [];
18
+ return param.properties.flatMap((prop) => {
19
+ if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
20
+ return [];
21
+ const method = propertyName(prop);
22
+ return method !== undefined && methods.has(method) ? [{ name: prop.value.name, method, step }] : [];
23
+ });
24
+ }
17
25
  function memberName(node) {
18
26
  if (!node.computed && node.property.type === 'Identifier')
19
27
  return node.property.name;
@@ -21,15 +29,35 @@ function memberName(node) {
21
29
  return node.property.value;
22
30
  return undefined;
23
31
  }
24
- function chainOf(node) {
32
+ /**
33
+ * The name a callee starts from and the members after it: `['describe', 'skip']` for
34
+ * `test.describe.skip`. A member called to build the function, as `each` in `test.each(table)` or
35
+ * ``test.each`...` ``, is written with parentheses: `['skip', 'each()']` for `test.skip.each(table)`.
36
+ */
37
+ function chainOf(node, factories) {
25
38
  const path = [];
26
39
  let current = node;
27
- while (current.type === 'MemberExpression') {
28
- const name = memberName(current);
29
- if (name === undefined)
40
+ for (;;) {
41
+ if (current.type === 'MemberExpression') {
42
+ const name = memberName(current);
43
+ if (name === undefined)
44
+ return undefined;
45
+ path.unshift(name);
46
+ current = current.object;
47
+ continue;
48
+ }
49
+ const factory = current.type === 'CallExpression'
50
+ ? current.callee
51
+ : current.type === 'TaggedTemplateExpression'
52
+ ? current.tag
53
+ : undefined;
54
+ if (factory?.type !== 'MemberExpression')
55
+ break;
56
+ const name = memberName(factory);
57
+ if (name === undefined || !factories.has(name))
30
58
  return undefined;
31
- path.unshift(name);
32
- current = current.object;
59
+ path.unshift(`${name}()`);
60
+ current = factory.object;
33
61
  }
34
62
  return current.type === 'Identifier' ? { root: current, path } : undefined;
35
63
  }
@@ -45,52 +73,75 @@ function unwrap(node) {
45
73
  function isFunction(node) {
46
74
  return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression';
47
75
  }
48
- /** Collects the top-level names that refer to Playwright's `test`. */
49
- function collectTestNames(program, configured) {
50
- const names = new Set(configured);
76
+ /** Collects the top-level names that refer to the framework's test functions or to its module. */
77
+ function collectTestNames(program, framework, configured) {
78
+ const roots = new Map(framework.globals);
79
+ for (const name of configured)
80
+ roots.set(name, TEST);
81
+ const modules = new Set();
51
82
  const mergeTests = new Set();
52
83
  /** Names bound to another test runner, such as `ava` in `import ava from 'ava'`. */
53
84
  const others = new Set();
54
- const isTestExpression = (node) => {
85
+ /** The member name of `pw.test` or `pw.mergeTests` on a framework module. */
86
+ const moduleMember = (node) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && modules.has(node.object.name)
87
+ ? memberName(node)
88
+ : undefined;
89
+ /** The test function an expression is, such as `base.extend({...})` or `mergeTests(a, b)`. */
90
+ const resolve = (node) => {
55
91
  const target = unwrap(node);
56
92
  if (target.type === 'Identifier')
57
- return names.has(target.name);
93
+ return roots.get(target.name);
94
+ const member = moduleMember(target);
95
+ if (member !== undefined)
96
+ return framework.exports.get(member);
58
97
  if (target.type !== 'CallExpression')
59
- return false;
60
- // mergeTests(dbTest, a11yTest)
98
+ return undefined;
99
+ // mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
61
100
  if (target.callee.type === 'Identifier')
62
- return mergeTests.has(target.callee.name);
101
+ return mergeTests.has(target.callee.name) ? TEST : undefined;
63
102
  if (target.callee.type !== 'MemberExpression')
64
- return false;
65
- return memberName(target.callee) === 'extend' && isTestExpression(target.callee.object);
103
+ return undefined;
104
+ if (moduleMember(target.callee) === 'mergeTests')
105
+ return TEST;
106
+ return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
66
107
  };
67
108
  // require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
68
109
  const isOtherFramework = (node) => {
69
110
  const target = unwrap(node);
70
111
  const source = requireSource(target);
71
112
  if (source !== undefined)
72
- return OTHER_FRAMEWORKS.has(source);
113
+ return isOtherRunner(framework, source);
73
114
  if (target.type === 'Identifier')
74
115
  return others.has(target.name);
75
116
  return target.type === 'MemberExpression' && isOtherFramework(target.object);
76
117
  };
118
+ const setOther = (name) => {
119
+ roots.delete(name);
120
+ others.add(name);
121
+ };
77
122
  for (const statement of program.body) {
78
123
  if (statement.type === 'ImportDeclaration') {
79
124
  const source = statement.source.value;
80
- if (OTHER_FRAMEWORKS.has(source)) {
81
- for (const spec of statement.specifiers) {
82
- names.delete(spec.local.name);
83
- others.add(spec.local.name);
84
- }
125
+ if (isOtherRunner(framework, source)) {
126
+ for (const spec of statement.specifiers)
127
+ setOther(spec.local.name);
85
128
  }
86
- else if (isPlaywrightModule(source)) {
129
+ else if (framework.ownsModule(source)) {
87
130
  for (const spec of statement.specifiers) {
88
- if (spec.type === 'ImportDefaultSpecifier')
89
- names.add(spec.local.name);
90
- else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'test')
91
- names.add(spec.local.name);
92
- else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'mergeTests')
93
- mergeTests.add(spec.local.name);
131
+ const local = spec.local.name;
132
+ if (spec.type === 'ImportNamespaceSpecifier')
133
+ modules.add(local);
134
+ else if (spec.type === 'ImportDefaultSpecifier') {
135
+ if (framework.defaultExport)
136
+ roots.set(local, framework.defaultExport);
137
+ }
138
+ else {
139
+ const root = framework.exports.get(importedName(spec));
140
+ if (root)
141
+ roots.set(local, root);
142
+ else if (importedName(spec) === 'mergeTests')
143
+ mergeTests.add(local);
144
+ }
94
145
  }
95
146
  }
96
147
  continue;
@@ -102,32 +153,47 @@ function collectTestNames(program, configured) {
102
153
  const init = declarator.init && unwrap(declarator.init);
103
154
  if (!init)
104
155
  continue;
105
- if (declarator.id.type === 'Identifier' && init.type === 'CallExpression' && isTestExpression(init)) {
106
- names.add(declarator.id.name);
107
- }
108
- else if (declarator.id.type === 'Identifier' && isOtherFramework(init)) {
156
+ if (declarator.id.type === 'Identifier') {
157
+ const name = declarator.id.name;
158
+ // `const t = base.extend({...})`, `const t = mergeTests(a, b)` or a plain alias, `const t = base`.
159
+ const root = resolve(init);
160
+ if (root)
161
+ roots.set(name, root);
109
162
  // `const test = require('node:test')` is another runner's `test`, like an import from it.
110
- names.delete(declarator.id.name);
111
- others.add(declarator.id.name);
163
+ else if (isOtherFramework(init))
164
+ setOther(name);
165
+ else {
166
+ // `const pw = require('@playwright/test')`, used as `pw.test`.
167
+ const source = requireSource(init);
168
+ if (source !== undefined && framework.ownsModule(source))
169
+ modules.add(name);
170
+ }
112
171
  }
113
172
  else if (declarator.id.type === 'ObjectPattern') {
114
173
  const source = requireSource(init);
115
174
  if (source === undefined)
116
175
  continue;
117
176
  for (const prop of declarator.id.properties) {
118
- if (prop.type !== 'Property' || prop.key.type !== 'Identifier' || prop.value.type !== 'Identifier')
177
+ if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
119
178
  continue;
120
- if (OTHER_FRAMEWORKS.has(source)) {
121
- names.delete(prop.value.name);
122
- others.add(prop.value.name);
179
+ if (isOtherRunner(framework, source))
180
+ setOther(prop.value.name);
181
+ else if (framework.ownsModule(source)) {
182
+ const root = framework.exports.get(propertyName(prop) ?? '');
183
+ if (root)
184
+ roots.set(prop.value.name, root);
123
185
  }
124
- else if (isPlaywrightModule(source) && prop.key.name === 'test')
125
- names.add(prop.value.name);
126
186
  }
127
187
  }
128
188
  }
129
189
  }
130
- return names;
190
+ return { roots, modules };
191
+ }
192
+ /** The name a property key spells out: `test` in `{ test }`, `{ 'test': t }` or `{ ['test']: t }`. */
193
+ function propertyName(prop) {
194
+ if (prop.key.type === 'Literal')
195
+ return typeof prop.key.value === 'string' ? prop.key.value : undefined;
196
+ return prop.computed ? undefined : prop.key.name;
131
197
  }
132
198
  function importedName(spec) {
133
199
  return spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
@@ -148,7 +214,9 @@ function isLocalVariable(sourceCode, id) {
148
214
  const variable = scope.set.get(id.name);
149
215
  if (!variable)
150
216
  continue;
151
- if (scope.type === 'module' || scope.type === 'global')
217
+ // A top-level scope: the global scope, a module's scope, or in CommonJS the function scope that
218
+ // wraps the module.
219
+ if (scope.block.type === 'Program')
152
220
  return false;
153
221
  return variable.defs.some((def) => def.type === 'Variable');
154
222
  }
@@ -193,8 +261,11 @@ export function parseTickets(text, offset, matcher) {
193
261
  const token = /^[^\s,]+/.exec(text.slice(index))?.[0];
194
262
  if (!token)
195
263
  break;
196
- // Punctuation right after a ticket (`WEB-1:` or `WEB-1.`) is not part of it.
264
+ // Punctuation right after a ticket (`TRADE-1:` or `TRADE-1.`) is not part of it.
197
265
  const ticket = token.replace(/[.:;]+$/, '');
266
+ // Punctuation alone, as in `// SKIP: .`, is no ticket at all.
267
+ if (ticket === '')
268
+ break;
198
269
  tickets.push({ text: ticket, result: matcher.check(ticket) });
199
270
  index += ticket.length;
200
271
  lastEnd = index;
@@ -204,19 +275,29 @@ export function parseTickets(text, offset, matcher) {
204
275
  index += separator.length;
205
276
  }
206
277
  const rest = text.slice(lastEnd).trimEnd();
207
- // A sentence-ending `WEB-1.` has nothing after the ticket; `WEB-1: flaky` has a note.
278
+ // A sentence-ending `TRADE-1.` has nothing after the ticket; `TRADE-1: flaky` has a note.
208
279
  if (tickets.length === 0 || /^[.:;]*$/.test(rest.trim()))
209
280
  return { tickets };
210
281
  return { tickets, extra: { text: rest.trim(), range: [offset + lastEnd, offset + lastEnd + rest.length] } };
211
282
  }
283
+ const LINE_BREAK_RE = /\r\n|[\r\n\u2028\u2029]/g;
284
+ /** The lines of a comment and where each starts in the source, split at every line break JavaScript has. */
285
+ export function commentLines(comment) {
286
+ const lines = [];
287
+ // `//` and `/*` are both two characters, so the comment's value starts two characters in.
288
+ const valueStart = comment.range[0] + 2;
289
+ let lineStart = 0;
290
+ for (const lineBreak of comment.value.matchAll(LINE_BREAK_RE)) {
291
+ lines.push({ text: comment.value.slice(lineStart, lineBreak.index), start: valueStart + lineStart });
292
+ lineStart = lineBreak.index + lineBreak[0].length;
293
+ }
294
+ lines.push({ text: comment.value.slice(lineStart), start: valueStart + lineStart });
295
+ return lines;
296
+ }
212
297
  function parseMarkers(comment, states) {
213
298
  const markers = [];
214
- // `//` and `/*` are both two characters, so the comment's value starts two characters in.
215
- let lineStart = comment.range[0] + 2;
216
- for (const line of comment.value.split(/\n/)) {
217
- const match = MARKER_LINE_RE.exec(line.replace(/\r$/, ''));
218
- const start = lineStart;
219
- lineStart += line.length + 1;
299
+ for (const { text, start } of commentLines(comment)) {
300
+ const match = MARKER_LINE_RE.exec(text);
220
301
  if (!match)
221
302
  continue;
222
303
  const [, keyword, rest] = match;
@@ -232,44 +313,77 @@ function parseMarkers(comment, states) {
232
313
  }
233
314
  return markers;
234
315
  }
235
- function scanTags(node, out) {
236
- const texts = node.type === 'Literal'
237
- ? [String(node.value)]
238
- : // `cooked` is only null in tagged templates, which are never titles.
316
+ function scanTags(source, node, out) {
317
+ // Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
318
+ const parts = node.type === 'Literal'
319
+ ? [{ text: String(node.value), start: node.range[0] + 1 }]
320
+ : node.quasis.map((quasi) => ({
321
+ // `cooked` is only null in tagged templates, which are never titles.
239
322
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
240
- node.quasis.map((quasi) => quasi.value.cooked);
241
- for (const text of texts) {
242
- for (const match of text.matchAll(TAG_RE))
243
- out.push({ tag: match[0], node });
323
+ text: quasi.value.cooked,
324
+ start: quasi.range[0] + 1,
325
+ }));
326
+ for (const { text, start } of parts) {
327
+ for (const match of text.matchAll(TAG_RE)) {
328
+ const end = match.index + match[0].length;
329
+ const written = source.slice(start, start + end) === text.slice(0, end);
330
+ out.push({ tag: match[0], node, ...(written && { range: [start + match.index, start + end] }) });
331
+ }
244
332
  }
245
333
  }
246
334
  function isStaticText(node) {
247
335
  return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
248
336
  }
249
- function collectTags(call, hasTitle) {
337
+ /**
338
+ * The plain-keyed properties of a test's details or options object, the second argument in
339
+ * `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`.
340
+ */
341
+ function detailsOf(call) {
342
+ const details = call.arguments.at(1);
343
+ if (details?.type !== 'ObjectExpression')
344
+ return [];
345
+ return details.properties.flatMap((prop) => {
346
+ if (prop.type !== 'Property' || prop.computed)
347
+ return [];
348
+ const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
349
+ return [[key, prop.value]];
350
+ });
351
+ }
352
+ function collectTags(source, call, hasTitle, tagKey) {
250
353
  const tags = [];
251
354
  if (!hasTitle)
252
355
  return { tags, dynamic: false };
253
- const [title, details] = call.arguments;
356
+ const [title] = call.arguments;
254
357
  const dynamic = !isStaticText(title);
255
358
  if (!dynamic)
256
- scanTags(title, tags);
257
- if (call.arguments.length >= 3 && details.type === 'ObjectExpression') {
258
- for (const prop of details.properties) {
259
- if (prop.type !== 'Property' || prop.computed)
260
- continue;
261
- const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
262
- if (key !== 'tag')
263
- continue;
264
- const values = prop.value.type === 'ArrayExpression' ? prop.value.elements : [prop.value];
265
- for (const value of values) {
266
- if (value && isStaticText(value))
267
- scanTags(value, tags);
268
- }
359
+ scanTags(source, title, tags);
360
+ for (const [key, value] of detailsOf(call)) {
361
+ if (key !== tagKey)
362
+ continue;
363
+ const values = value.type === 'ArrayExpression' ? value.elements : [value];
364
+ for (const item of values) {
365
+ if (item && isStaticText(item))
366
+ scanTags(source, item, tags);
269
367
  }
270
368
  }
271
369
  return { tags, dynamic };
272
370
  }
371
+ /**
372
+ * Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)` or
373
+ * `test('title', details, fn)`, a static title with a body defined elsewhere, as in
374
+ * `test.skip('pays', payWithCard)`, a describe with only a body, or a `todo` with only a title. A string
375
+ * title tells `test.skip('pays', payWithCard)` apart from a runtime `test.skip(isMobile, 'why')`.
376
+ */
377
+ function isDeclaration(kind, args, todo) {
378
+ const first = args.at(0);
379
+ if (isFunction(first))
380
+ return kind === 'describe';
381
+ if (first === undefined)
382
+ return false;
383
+ if (args.length === 1)
384
+ return todo;
385
+ return isStaticText(first) || args.slice(1).some(isFunction);
386
+ }
273
387
  function isUnconditional(call) {
274
388
  const first = call.arguments.at(0);
275
389
  return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
@@ -311,6 +425,18 @@ function isGuarded(call) {
311
425
  }
312
426
  return false;
313
427
  }
428
+ /** Keys that point back up the tree or at comments rather than at child nodes. */
429
+ const NON_CHILD_KEYS = new Set(['parent', 'leadingComments', 'trailingComments']);
430
+ /**
431
+ * The keys of a node type that the parser gave no visitor keys for, read from the node itself, as
432
+ * ESLint's own traverser does. Without this, tests inside such a node would go unchecked.
433
+ */
434
+ function ownKeys(node) {
435
+ return Object.keys(node).filter((key) => !NON_CHILD_KEYS.has(key) && !key.startsWith('_'));
436
+ }
437
+ function isNode(value) {
438
+ return typeof value?.type === 'string';
439
+ }
314
440
  const cache = new WeakMap();
315
441
  export function analyze(sourceCode, options) {
316
442
  let byOptions = cache.get(sourceCode);
@@ -326,19 +452,39 @@ export function analyze(sourceCode, options) {
326
452
  return analysis;
327
453
  }
328
454
  function runAnalysis(sourceCode, options) {
329
- const { states } = options;
330
- const testNames = collectTestNames(sourceCode.ast, options.testFunctions);
331
- let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && isPlaywrightModule(statement.source.value));
455
+ const { states, framework } = options;
456
+ const testNames = collectTestNames(sourceCode.ast, framework, options.testFunctions);
457
+ /**
458
+ * The test function a callee starts from and the members after it, such as `test` and
459
+ * `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
460
+ * isn't one of the framework's test functions.
461
+ */
462
+ const target = (chain) => {
463
+ if (chain === undefined)
464
+ return undefined;
465
+ const { root, path } = chain;
466
+ let base = testNames.roots.get(root.name);
467
+ let rest = path;
468
+ if (base === undefined && testNames.modules.has(root.name)) {
469
+ base = framework.exports.get(path[0]);
470
+ rest = path.slice(1);
471
+ }
472
+ return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest } : undefined;
473
+ };
474
+ let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
332
475
  const subjects = [];
333
476
  const claimed = new Set();
334
477
  const stack = [];
335
- const testInfoNames = [];
478
+ const infos = [];
336
479
  const keys = sourceCode.visitorKeys;
337
- const makeSubject = (kind, call, modifiers, tagInfo, required = true) => {
338
- const parent = stack.at(-1);
480
+ /**
481
+ * `modifiers` maps each state modifier the call has to whether it always applies; a conditional one,
482
+ * like `test.skip(isMobile)` or Vitest's `skipIf(...)`, needs a ticket only with `requireTicketForConditional`.
483
+ */
484
+ const makeSubject = (kind, call, modifiers, tagInfo, parent = stack.at(-1)) => {
339
485
  const anchor = anchorOf(call);
340
486
  const block = commentBlock(sourceCode, anchor, options.allowBlankLine);
341
- // `rows.forEach((row) =>\n // SKIP: WEB-1\n test.skip(...))`: the marker sits above the call itself.
487
+ // `symbols.forEach((symbol) =>\n // SKIP: TRADE-1\n test.skip(...))`: the marker sits above the call itself.
342
488
  if (call.parent.type === 'ArrowFunctionExpression' && call.parent.body === call) {
343
489
  block.push(...commentBlock(sourceCode, call, options.allowBlankLine));
344
490
  }
@@ -347,9 +493,14 @@ function runAnalysis(sourceCode, options) {
347
493
  const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
348
494
  const sources = [];
349
495
  for (const state of states) {
350
- const matches = state.modifier !== undefined ? modifiers.includes(state.modifier) : ownTags.has(state.tag);
351
- if (matches)
352
- sources.push({ state, required });
496
+ if (state.modifier === undefined) {
497
+ if (ownTags.has(state.tag))
498
+ sources.push({ state, required: true });
499
+ continue;
500
+ }
501
+ const always = modifiers.get(state.modifier);
502
+ if (always !== undefined)
503
+ sources.push({ state, required: always || options.requireTicketForConditional });
353
504
  }
354
505
  const inherited = new Set();
355
506
  const inheritedTags = new Set();
@@ -358,12 +509,15 @@ function runAnalysis(sourceCode, options) {
358
509
  inherited.add(name);
359
510
  for (const source of parent.states)
360
511
  inherited.add(source.state.name);
512
+ }
513
+ // Tags belong to tests: a step in a `@new` test is not itself new.
514
+ if (parent && (kind === 'test' || kind === 'describe')) {
361
515
  for (const tag of parent.inheritedTags)
362
516
  inheritedTags.add(tag);
363
517
  for (const tag of parent.tags)
364
518
  inheritedTags.add(tag.tag);
365
519
  }
366
- const ownSkip = modifiers.some((m) => SKIPPING_MODIFIERS.has(m));
520
+ const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
367
521
  const subject = {
368
522
  kind,
369
523
  node: call,
@@ -382,60 +536,102 @@ function runAnalysis(sourceCode, options) {
382
536
  subjects.push(subject);
383
537
  return subject;
384
538
  };
539
+ /** The state modifiers of a declaration, from its members and, in Vitest, its options object. */
540
+ const modifiersOf = (members, call) => {
541
+ const modifiers = new Map();
542
+ const add = (modifier, always) => {
543
+ if (!modifiers.get(modifier))
544
+ modifiers.set(modifier, always);
545
+ };
546
+ for (const member of members) {
547
+ const effect = framework.effects.get(member);
548
+ if (effect)
549
+ add(effect.modifier, !effect.conditional);
550
+ }
551
+ // `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
552
+ for (const [key, value] of detailsOf(call)) {
553
+ const modifier = framework.optionEffects.get(key);
554
+ const literal = value.type === 'Literal' ? value.value : undefined;
555
+ if (modifier && literal !== false)
556
+ add(modifier, literal === true);
557
+ }
558
+ return modifiers;
559
+ };
560
+ /** What a body can change the test's state with: Playwright's `testInfo` or Vitest's test context. */
561
+ const bodyInfos = (args, path) => {
562
+ const body = args.findLast(isFunction);
563
+ // `test.each(table)` passes the row to the body, and `test.for(cases)` passes it before the context.
564
+ if (!framework.info || !body || path.includes('each()'))
565
+ return [];
566
+ const param = body.params.at(framework.info.param + (path.includes('for()') ? 1 : 0));
567
+ return infoNames(param, framework.info.methods);
568
+ };
385
569
  const visitCall = (call) => {
386
- const chain = chainOf(call.callee);
570
+ const callee = target(chainOf(call.callee, framework.factories));
571
+ if (callee === undefined)
572
+ return { subject: runtimeCall(call) };
573
+ usesTest = true;
387
574
  const args = call.arguments;
388
- const last = args.at(-1);
389
- let testInfo;
390
- if (chain && testNames.has(chain.root.name) && !isLocalVariable(sourceCode, chain.root)) {
391
- usesTest = true;
392
- const { path } = chain;
393
- if (isFunction(last)) {
394
- const param = last.params.at(1);
395
- if (param?.type === 'Identifier')
396
- testInfo = param.name;
397
- }
398
- // The body is usually written inline, but can be a function defined elsewhere:
399
- // `test.skip('pays', payWithCard)`. A string title tells that apart from `test.skip(cond, 'why')`.
400
- const titled = args.length >= 2 && isStaticText(args[0]);
401
- const isDeclaration = args.length >= 2 && !isFunction(args[0]) && (isFunction(last) || titled);
402
- if (path.every((p) => TEST_MODIFIERS.has(p)) && isDeclaration) {
403
- return { subject: makeSubject('test', call, path, collectTags(call, true)), testInfo };
404
- }
405
- if (path[0] === 'describe' &&
406
- path.slice(1).every((p) => DESCRIBE_MODIFIERS.has(p)) &&
407
- (isFunction(last) || titled)) {
408
- const tagInfo = collectTags(call, args.length >= 2);
409
- return { subject: makeSubject('describe', call, path.slice(1), tagInfo), testInfo };
575
+ const { root } = callee;
576
+ let { path } = callee;
577
+ if (framework.playwright) {
578
+ // `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
579
+ // gets a `TestStepInfo` that can skip the step at runtime.
580
+ const step = path.join('.');
581
+ if ((step === 'step' || step === 'step.skip') && args.length >= 2) {
582
+ const subject = makeSubject('step', call, modifiersOf(path.slice(1), call), { tags: [], dynamic: false });
583
+ const body = args[1];
584
+ const param = isFunction(body) ? body.params.at(0) : undefined;
585
+ return { subject, infos: infoNames(param, STEP_INFO_METHODS, subject) };
410
586
  }
411
- if (path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
412
- return { subject: runtimeSubject(call, path[0]), testInfo };
413
- }
414
- return { testInfo };
415
587
  }
416
- // testInfo.skip() and test.info().skip()
417
- if (call.callee.type === 'MemberExpression') {
418
- const name = memberName(call.callee);
419
- const object = call.callee.object;
420
- if (name !== undefined && RUNTIME_MODIFIERS.has(name)) {
421
- const isTestInfo = (object.type === 'Identifier' && testInfoNames.includes(object.name)) ||
422
- (object.type === 'CallExpression' &&
423
- (() => {
424
- const inner = chainOf(object.callee);
425
- return inner !== undefined && testNames.has(inner.root.name) && inner.path.join('.') === 'info';
426
- })());
427
- if (isTestInfo)
428
- return { subject: runtimeSubject(call, name) };
429
- }
588
+ const bodyInfo = bodyInfos(args, path);
589
+ let { kind } = root;
590
+ if (framework.playwright && path[0] === 'describe') {
591
+ kind = 'describe';
592
+ path = path.slice(1);
593
+ }
594
+ const members = [...root.modifiers, ...path];
595
+ const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
596
+ if (members.every((m) => allowed.has(m)) && isDeclaration(kind, args, members.includes('todo'))) {
597
+ // A declaration always has a first argument: its title, or the body of a describe without one.
598
+ const tagInfo = collectTags(sourceCode.text, call, !isFunction(args[0]), framework.tagKey);
599
+ return { subject: makeSubject(kind, call, modifiersOf(members, call), tagInfo), infos: bodyInfo };
600
+ }
601
+ // test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
602
+ if (framework.playwright && path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
603
+ return { subject: runtimeSubject(call, path[0]), infos: bodyInfo };
430
604
  }
431
- return {};
605
+ return { infos: bodyInfo };
432
606
  };
433
- const runtimeSubject = (call, modifier) => {
607
+ /** `testInfo.skip()`, `step.skip()`, `test.info().skip()`, and Vitest's `context.skip()` or `skip()`. */
608
+ const runtimeCall = (call) => {
609
+ const { callee } = call;
610
+ // The innermost parameter with a name is the one the call uses.
611
+ const innermost = (name) => infos.findLast((info) => info.name === name);
612
+ if (callee.type === 'Identifier') {
613
+ const info = innermost(callee.name);
614
+ return info?.method !== undefined ? runtimeSubject(call, info.method, info.step) : undefined;
615
+ }
616
+ if (callee.type !== 'MemberExpression')
617
+ return undefined;
618
+ const name = memberName(callee);
619
+ if (name === undefined || !RUNTIME_MODIFIERS.has(name))
620
+ return undefined;
621
+ const { object } = callee;
622
+ if (object.type === 'Identifier') {
623
+ const info = innermost(object.name);
624
+ return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
625
+ }
626
+ const isTestInfo = object.type === 'CallExpression' &&
627
+ target(chainOf(object.callee, framework.factories))?.path.join('.') === 'info';
628
+ return isTestInfo ? runtimeSubject(call, name) : undefined;
629
+ };
630
+ /** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
631
+ const runtimeSubject = (call, modifier, step) => {
434
632
  if (!states.some((s) => s.modifier === modifier))
435
633
  return undefined;
436
- const unconditional = isUnconditional(call);
437
- const subject = makeSubject('runtime', call, [modifier], { tags: [], dynamic: false }, unconditional || options.requireTicketForConditional);
438
- subject.skipped = unconditional && SKIPPING_MODIFIERS.has(modifier);
634
+ const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]), { tags: [], dynamic: false }, step ?? stack.findLast((s) => s.kind !== 'step'));
439
635
  const parent = subject.parent;
440
636
  if (parent) {
441
637
  parent.runtimeStates.add(subject.states[0].state.name);
@@ -446,35 +642,27 @@ function runAnalysis(sourceCode, options) {
446
642
  };
447
643
  const walk = (node) => {
448
644
  let pushedSubject = false;
449
- let pushedInfo = false;
645
+ let pushedInfos = 0;
450
646
  if (node.type === 'CallExpression') {
451
- const { subject, testInfo } = visitCall(node);
452
- if (subject && subject.kind !== 'runtime') {
453
- stack.push(subject);
647
+ const visit = visitCall(node);
648
+ if (visit.subject && visit.subject.kind !== 'runtime') {
649
+ stack.push(visit.subject);
454
650
  pushedSubject = true;
455
651
  }
456
- if (testInfo !== undefined) {
457
- testInfoNames.push(testInfo);
458
- pushedInfo = true;
652
+ for (const info of visit.infos ?? []) {
653
+ infos.push(info);
654
+ pushedInfos++;
459
655
  }
460
656
  }
461
- // Parsers provide visitor keys for every node type they produce; the fallback is only defensive.
462
- /* node:coverage ignore next */
463
- for (const key of keys[node.type] ?? []) {
657
+ for (const key of keys[node.type] ?? ownKeys(node)) {
464
658
  const child = node[key];
465
- if (Array.isArray(child)) {
466
- for (const item of child)
467
- if (item)
468
- walk(item);
469
- }
470
- else if (child && typeof child.type === 'string') {
471
- walk(child);
472
- }
659
+ for (const item of Array.isArray(child) ? child : [child])
660
+ if (isNode(item))
661
+ walk(item);
473
662
  }
474
663
  if (pushedSubject)
475
664
  stack.pop();
476
- if (pushedInfo)
477
- testInfoNames.pop();
665
+ infos.splice(infos.length - pushedInfos, pushedInfos);
478
666
  };
479
667
  walk(sourceCode.ast);
480
668
  const detachedMarkers = sourceCode
@@ -511,7 +699,7 @@ export function evaluate(subject, state) {
511
699
  }
512
700
  const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
513
701
  // A broken marker in the subject's own block is reported even when another marker is valid: in
514
- // `// SKIP: WEB-1` + `// SKIP: nope`, the second line is still wrong. Broken markers further up
702
+ // `// SKIP: TRADE-1` + `// SKIP: nope`, the second line is still wrong. Broken markers further up
515
703
  // are reported for the test or describe they sit above.
516
704
  const brokenOwn = markers.find((m) => subject.markers.includes(m) && !isValid(m));
517
705
  if (!brokenOwn && markers.some(isValid))
@@ -538,7 +726,7 @@ export function isProse(marker) {
538
726
  * is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
539
727
  * reports those; the marker rules leave them alone.
540
728
  */
541
- export function isWorkComment(marker, options) {
729
+ function isWorkComment(marker, options) {
542
730
  return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
543
731
  }
544
732
  /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
@@ -549,21 +737,24 @@ export function strayMarkers(subject, options) {
549
737
  }
550
738
  /**
551
739
  * Where to report a problem with a test or describe: from the callee to the end of the title
552
- * (`test.skip('pays with PayPal'`), so editors underline the declaration rather than the whole body.
740
+ * (`test.skip('places a limit order'`), so editors underline the declaration rather than the whole body.
553
741
  * Runtime calls such as `test.skip()` are short, so they are reported whole.
554
742
  */
555
743
  export function headLoc(subject) {
556
744
  const { node } = subject;
557
745
  if (subject.kind === 'runtime')
558
746
  return node.loc;
559
- const title = node.arguments.length > 1 ? node.arguments[0] : undefined;
747
+ // A describe can have a body and no title. A todo test has only a title.
748
+ const [first] = node.arguments;
749
+ const title = isFunction(first) ? undefined : first;
560
750
  return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
561
751
  }
562
752
  export function describeSubject(subject) {
563
753
  if (subject.kind === 'describe')
564
754
  return 'This describe block';
755
+ if (subject.kind === 'step')
756
+ return 'This step';
565
757
  if (subject.kind === 'runtime')
566
758
  return `This ${subject.states[0].state.modifier} call`;
567
759
  return 'This test';
568
760
  }
569
- //# sourceMappingURL=analyze.js.map