eslint-plugin-test-governance 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +166 -64
  3. package/dist/index.d.ts +14 -12
  4. package/dist/index.js +22 -12
  5. package/dist/rules/marker-matches-state.d.ts +1 -2
  6. package/dist/rules/marker-matches-state.js +7 -7
  7. package/dist/rules/no-conflicting-states.d.ts +1 -2
  8. package/dist/rules/no-conflicting-states.js +12 -20
  9. package/dist/rules/no-orphaned-marker.d.ts +1 -2
  10. package/dist/rules/no-orphaned-marker.js +15 -10
  11. package/dist/rules/require-ticket-in-comments.d.ts +1 -2
  12. package/dist/rules/require-ticket-in-comments.js +13 -18
  13. package/dist/rules/require-ticket.d.ts +1 -2
  14. package/dist/rules/require-ticket.js +18 -10
  15. package/dist/utils/analyze.d.ts +17 -14
  16. package/dist/utils/analyze.js +370 -153
  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 +9 -10
  20. package/dist/utils/create-rule.js +5 -11
  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 +26 -11
  30. package/dist/utils/options.js +13 -9
  31. package/dist/utils/schema.d.ts +1 -2
  32. package/dist/utils/schema.js +2 -2
  33. package/dist/utils/tickets.d.ts +0 -1
  34. package/dist/utils/tickets.js +4 -4
  35. package/dist/utils/validate.d.ts +0 -1
  36. package/dist/utils/validate.js +3 -3
  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 +7 -10
@@ -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,38 +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
- const isTestExpression = (node) => {
83
+ /** Names bound to another test runner, such as `ava` in `import ava from 'ava'`. */
84
+ const others = new Set();
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) => {
53
91
  const target = unwrap(node);
54
92
  if (target.type === 'Identifier')
55
- 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);
56
97
  if (target.type !== 'CallExpression')
57
- return false;
58
- // mergeTests(dbTest, a11yTest)
98
+ return undefined;
99
+ // mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
59
100
  if (target.callee.type === 'Identifier')
60
- return mergeTests.has(target.callee.name);
101
+ return mergeTests.has(target.callee.name) ? TEST : undefined;
61
102
  if (target.callee.type !== 'MemberExpression')
62
- return false;
63
- 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;
107
+ };
108
+ // require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
109
+ const isOtherFramework = (node) => {
110
+ const target = unwrap(node);
111
+ const source = requireSource(target);
112
+ if (source !== undefined)
113
+ return isOtherRunner(framework, source);
114
+ if (target.type === 'Identifier')
115
+ return others.has(target.name);
116
+ return target.type === 'MemberExpression' && isOtherFramework(target.object);
117
+ };
118
+ const setOther = (name) => {
119
+ roots.delete(name);
120
+ others.add(name);
64
121
  };
65
122
  for (const statement of program.body) {
66
123
  if (statement.type === 'ImportDeclaration') {
67
124
  const source = statement.source.value;
68
- if (OTHER_FRAMEWORKS.has(source)) {
125
+ if (isOtherRunner(framework, source)) {
69
126
  for (const spec of statement.specifiers)
70
- names.delete(spec.local.name);
127
+ setOther(spec.local.name);
71
128
  }
72
- else if (isPlaywrightModule(source)) {
129
+ else if (framework.ownsModule(source)) {
73
130
  for (const spec of statement.specifiers) {
74
- if (spec.type === 'ImportDefaultSpecifier')
75
- names.add(spec.local.name);
76
- else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'test')
77
- names.add(spec.local.name);
78
- else if (spec.type === 'ImportSpecifier' && importedName(spec) === 'mergeTests')
79
- 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
+ }
80
145
  }
81
146
  }
82
147
  continue;
@@ -88,25 +153,47 @@ function collectTestNames(program, configured) {
88
153
  const init = declarator.init && unwrap(declarator.init);
89
154
  if (!init)
90
155
  continue;
91
- if (declarator.id.type === 'Identifier' && init.type === 'CallExpression' && isTestExpression(init)) {
92
- names.add(declarator.id.name);
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);
162
+ // `const test = require('node:test')` is another runner's `test`, like an import from it.
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
+ }
93
171
  }
94
172
  else if (declarator.id.type === 'ObjectPattern') {
95
173
  const source = requireSource(init);
96
174
  if (source === undefined)
97
175
  continue;
98
176
  for (const prop of declarator.id.properties) {
99
- if (prop.type !== 'Property' || prop.key.type !== 'Identifier' || prop.value.type !== 'Identifier')
177
+ if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
100
178
  continue;
101
- if (OTHER_FRAMEWORKS.has(source))
102
- names.delete(prop.value.name);
103
- else if (isPlaywrightModule(source) && prop.key.name === 'test')
104
- names.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);
185
+ }
105
186
  }
106
187
  }
107
188
  }
108
189
  }
109
- 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;
110
197
  }
111
198
  function importedName(spec) {
112
199
  return spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
@@ -127,7 +214,9 @@ function isLocalVariable(sourceCode, id) {
127
214
  const variable = scope.set.get(id.name);
128
215
  if (!variable)
129
216
  continue;
130
- 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')
131
220
  return false;
132
221
  return variable.defs.some((def) => def.type === 'Variable');
133
222
  }
@@ -172,8 +261,11 @@ export function parseTickets(text, offset, matcher) {
172
261
  const token = /^[^\s,]+/.exec(text.slice(index))?.[0];
173
262
  if (!token)
174
263
  break;
175
- // 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.
176
265
  const ticket = token.replace(/[.:;]+$/, '');
266
+ // Punctuation alone, as in `// SKIP: .`, is no ticket at all.
267
+ if (ticket === '')
268
+ break;
177
269
  tickets.push({ text: ticket, result: matcher.check(ticket) });
178
270
  index += ticket.length;
179
271
  lastEnd = index;
@@ -183,19 +275,29 @@ export function parseTickets(text, offset, matcher) {
183
275
  index += separator.length;
184
276
  }
185
277
  const rest = text.slice(lastEnd).trimEnd();
186
- // 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.
187
279
  if (tickets.length === 0 || /^[.:;]*$/.test(rest.trim()))
188
280
  return { tickets };
189
281
  return { tickets, extra: { text: rest.trim(), range: [offset + lastEnd, offset + lastEnd + rest.length] } };
190
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
+ }
191
297
  function parseMarkers(comment, states) {
192
298
  const markers = [];
193
- // `//` and `/*` are both two characters, so the comment's value starts two characters in.
194
- let lineStart = comment.range[0] + 2;
195
- for (const line of comment.value.split(/\n/)) {
196
- const match = MARKER_LINE_RE.exec(line.replace(/\r$/, ''));
197
- const start = lineStart;
198
- lineStart += line.length + 1;
299
+ for (const { text, start } of commentLines(comment)) {
300
+ const match = MARKER_LINE_RE.exec(text);
199
301
  if (!match)
200
302
  continue;
201
303
  const [, keyword, rest] = match;
@@ -211,44 +313,77 @@ function parseMarkers(comment, states) {
211
313
  }
212
314
  return markers;
213
315
  }
214
- function scanTags(node, out) {
215
- const texts = node.type === 'Literal'
216
- ? [String(node.value)]
217
- : // `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.
218
322
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
219
- node.quasis.map((quasi) => quasi.value.cooked);
220
- for (const text of texts) {
221
- for (const match of text.matchAll(TAG_RE))
222
- 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
+ }
223
332
  }
224
333
  }
225
334
  function isStaticText(node) {
226
335
  return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
227
336
  }
228
- 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) {
229
353
  const tags = [];
230
354
  if (!hasTitle)
231
355
  return { tags, dynamic: false };
232
- const [title, details] = call.arguments;
356
+ const [title] = call.arguments;
233
357
  const dynamic = !isStaticText(title);
234
358
  if (!dynamic)
235
- scanTags(title, tags);
236
- if (call.arguments.length >= 3 && details.type === 'ObjectExpression') {
237
- for (const prop of details.properties) {
238
- if (prop.type !== 'Property' || prop.computed)
239
- continue;
240
- const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
241
- if (key !== 'tag')
242
- continue;
243
- const values = prop.value.type === 'ArrayExpression' ? prop.value.elements : [prop.value];
244
- for (const value of values) {
245
- if (value && isStaticText(value))
246
- scanTags(value, tags);
247
- }
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);
248
367
  }
249
368
  }
250
369
  return { tags, dynamic };
251
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
+ }
252
387
  function isUnconditional(call) {
253
388
  const first = call.arguments.at(0);
254
389
  return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
@@ -290,6 +425,18 @@ function isGuarded(call) {
290
425
  }
291
426
  return false;
292
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
+ }
293
440
  const cache = new WeakMap();
294
441
  export function analyze(sourceCode, options) {
295
442
  let byOptions = cache.get(sourceCode);
@@ -305,19 +452,39 @@ export function analyze(sourceCode, options) {
305
452
  return analysis;
306
453
  }
307
454
  function runAnalysis(sourceCode, options) {
308
- const { states } = options;
309
- const testNames = collectTestNames(sourceCode.ast, options.testFunctions);
310
- 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));
311
475
  const subjects = [];
312
476
  const claimed = new Set();
313
477
  const stack = [];
314
- const testInfoNames = [];
478
+ const infos = [];
315
479
  const keys = sourceCode.visitorKeys;
316
- const makeSubject = (kind, call, modifiers, tagInfo, required = true) => {
317
- 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)) => {
318
485
  const anchor = anchorOf(call);
319
486
  const block = commentBlock(sourceCode, anchor, options.allowBlankLine);
320
- // `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.
321
488
  if (call.parent.type === 'ArrowFunctionExpression' && call.parent.body === call) {
322
489
  block.push(...commentBlock(sourceCode, call, options.allowBlankLine));
323
490
  }
@@ -326,9 +493,14 @@ function runAnalysis(sourceCode, options) {
326
493
  const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
327
494
  const sources = [];
328
495
  for (const state of states) {
329
- const matches = state.modifier !== undefined ? modifiers.includes(state.modifier) : ownTags.has(state.tag);
330
- if (matches)
331
- 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 });
332
504
  }
333
505
  const inherited = new Set();
334
506
  const inheritedTags = new Set();
@@ -337,12 +509,15 @@ function runAnalysis(sourceCode, options) {
337
509
  inherited.add(name);
338
510
  for (const source of parent.states)
339
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')) {
340
515
  for (const tag of parent.inheritedTags)
341
516
  inheritedTags.add(tag);
342
517
  for (const tag of parent.tags)
343
518
  inheritedTags.add(tag.tag);
344
519
  }
345
- const ownSkip = modifiers.some((m) => SKIPPING_MODIFIERS.has(m));
520
+ const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
346
521
  const subject = {
347
522
  kind,
348
523
  node: call,
@@ -361,55 +536,102 @@ function runAnalysis(sourceCode, options) {
361
536
  subjects.push(subject);
362
537
  return subject;
363
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
+ };
364
569
  const visitCall = (call) => {
365
- 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;
366
574
  const args = call.arguments;
367
- const last = args.at(-1);
368
- let testInfo;
369
- if (chain && testNames.has(chain.root.name) && !isLocalVariable(sourceCode, chain.root)) {
370
- usesTest = true;
371
- const { path } = chain;
372
- if (isFunction(last)) {
373
- const param = last.params.at(1);
374
- if (param?.type === 'Identifier')
375
- testInfo = param.name;
376
- }
377
- const isDeclaration = args.length >= 2 && isFunction(last) && !isFunction(args[0]);
378
- if (path.every((p) => TEST_MODIFIERS.has(p)) && isDeclaration) {
379
- return { subject: makeSubject('test', call, path, collectTags(call, true)), testInfo };
380
- }
381
- if (path[0] === 'describe' && path.slice(1).every((p) => DESCRIBE_MODIFIERS.has(p)) && isFunction(last)) {
382
- const tagInfo = collectTags(call, args.length >= 2);
383
- 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) };
384
586
  }
385
- if (path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
386
- return { subject: runtimeSubject(call, path[0]), testInfo };
387
- }
388
- return { testInfo };
389
587
  }
390
- // testInfo.skip() and test.info().skip()
391
- if (call.callee.type === 'MemberExpression') {
392
- const name = memberName(call.callee);
393
- const object = call.callee.object;
394
- if (name !== undefined && RUNTIME_MODIFIERS.has(name)) {
395
- const isTestInfo = (object.type === 'Identifier' && testInfoNames.includes(object.name)) ||
396
- (object.type === 'CallExpression' &&
397
- (() => {
398
- const inner = chainOf(object.callee);
399
- return inner !== undefined && testNames.has(inner.root.name) && inner.path.join('.') === 'info';
400
- })());
401
- if (isTestInfo)
402
- return { subject: runtimeSubject(call, name) };
403
- }
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 };
404
604
  }
405
- return {};
605
+ return { infos: bodyInfo };
406
606
  };
407
- 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) => {
408
632
  if (!states.some((s) => s.modifier === modifier))
409
633
  return undefined;
410
- const unconditional = isUnconditional(call);
411
- const subject = makeSubject('runtime', call, [modifier], { tags: [], dynamic: false }, unconditional || options.requireTicketForConditional);
412
- 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'));
413
635
  const parent = subject.parent;
414
636
  if (parent) {
415
637
  parent.runtimeStates.add(subject.states[0].state.name);
@@ -420,35 +642,27 @@ function runAnalysis(sourceCode, options) {
420
642
  };
421
643
  const walk = (node) => {
422
644
  let pushedSubject = false;
423
- let pushedInfo = false;
645
+ let pushedInfos = 0;
424
646
  if (node.type === 'CallExpression') {
425
- const { subject, testInfo } = visitCall(node);
426
- if (subject && subject.kind !== 'runtime') {
427
- stack.push(subject);
647
+ const visit = visitCall(node);
648
+ if (visit.subject && visit.subject.kind !== 'runtime') {
649
+ stack.push(visit.subject);
428
650
  pushedSubject = true;
429
651
  }
430
- if (testInfo !== undefined) {
431
- testInfoNames.push(testInfo);
432
- pushedInfo = true;
652
+ for (const info of visit.infos ?? []) {
653
+ infos.push(info);
654
+ pushedInfos++;
433
655
  }
434
656
  }
435
- // Parsers provide visitor keys for every node type they produce; the fallback is only defensive.
436
- /* v8 ignore next */
437
- for (const key of keys[node.type] ?? []) {
657
+ for (const key of keys[node.type] ?? ownKeys(node)) {
438
658
  const child = node[key];
439
- if (Array.isArray(child)) {
440
- for (const item of child)
441
- if (item)
442
- walk(item);
443
- }
444
- else if (child && typeof child.type === 'string') {
445
- walk(child);
446
- }
659
+ for (const item of Array.isArray(child) ? child : [child])
660
+ if (isNode(item))
661
+ walk(item);
447
662
  }
448
663
  if (pushedSubject)
449
664
  stack.pop();
450
- if (pushedInfo)
451
- testInfoNames.pop();
665
+ infos.splice(infos.length - pushedInfos, pushedInfos);
452
666
  };
453
667
  walk(sourceCode.ast);
454
668
  const detachedMarkers = sourceCode
@@ -485,7 +699,7 @@ export function evaluate(subject, state) {
485
699
  }
486
700
  const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
487
701
  // A broken marker in the subject's own block is reported even when another marker is valid: in
488
- // `// 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
489
703
  // are reported for the test or describe they sit above.
490
704
  const brokenOwn = markers.find((m) => subject.markers.includes(m) && !isValid(m));
491
705
  if (!brokenOwn && markers.some(isValid))
@@ -512,7 +726,7 @@ export function isProse(marker) {
512
726
  * is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
513
727
  * reports those; the marker rules leave them alone.
514
728
  */
515
- export function isWorkComment(marker, options) {
729
+ function isWorkComment(marker, options) {
516
730
  return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
517
731
  }
518
732
  /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
@@ -523,21 +737,24 @@ export function strayMarkers(subject, options) {
523
737
  }
524
738
  /**
525
739
  * Where to report a problem with a test or describe: from the callee to the end of the title
526
- * (`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.
527
741
  * Runtime calls such as `test.skip()` are short, so they are reported whole.
528
742
  */
529
743
  export function headLoc(subject) {
530
744
  const { node } = subject;
531
745
  if (subject.kind === 'runtime')
532
746
  return node.loc;
533
- 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;
534
750
  return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
535
751
  }
536
752
  export function describeSubject(subject) {
537
753
  if (subject.kind === 'describe')
538
754
  return 'This describe block';
755
+ if (subject.kind === 'step')
756
+ return 'This step';
539
757
  if (subject.kind === 'runtime')
540
758
  return `This ${subject.states[0].state.modifier} call`;
541
759
  return 'This test';
542
760
  }
543
- //# sourceMappingURL=analyze.js.map