eslint-plugin-test-governance 0.13.2 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.13.3] - 2026-11-09
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Vitest conditional factories can clear an earlier skip in a modifier chain. Runtime context methods
|
|
14
|
+
retain their state through TypeScript wrappers on the receiver or method.
|
|
15
|
+
- Mutation detection follows wrapped references and aliases, and recognizes writes through the global
|
|
16
|
+
`Object` and `Reflect` mutation methods without treating shadowed helpers as those built-ins.
|
|
17
|
+
- Retry counts are read through local constants and object members, with effective properties after
|
|
18
|
+
spreads and overrides. Unread spreads remain possible retries unless every relevant count is known.
|
|
19
|
+
- Commented-out tests no longer hide a broken marker or a disallowed note behind another valid marker.
|
|
20
|
+
- Negative numeric and bigint template interpolations, `void`, and unshadowed `undefined`, `NaN` and
|
|
21
|
+
`Infinity` retain their rendered text when checking tag boundaries.
|
|
22
|
+
- Mutable constant objects and arrays no longer supply stale tag or annotation values after detected
|
|
23
|
+
property writes, deletion, array mutation or writes through a local alias.
|
|
24
|
+
- Runtime context methods keep their original test owner inside nested subtests. Vitest skip notes
|
|
25
|
+
supplied through constants remain unconditional skips.
|
|
26
|
+
- Test callees retain their meaning through TypeScript assertions and type instantiations, sequence
|
|
27
|
+
expressions and defaulted local destructuring. Repeated conditional factories use the final condition.
|
|
28
|
+
- Nested conditional returns and throws, and conditional loop breaks and continues, guard later runtime
|
|
29
|
+
skips. Jest retry calls are recognized through CommonJS object bindings and method aliases, including
|
|
30
|
+
bound arguments.
|
|
31
|
+
- Primitive template interpolations retain their rendered text when checking tag boundaries. Object
|
|
32
|
+
prototype setters do not count as own tag or metadata properties, and suggestions use computed keys
|
|
33
|
+
for metadata named `__proto__`.
|
|
34
|
+
- Test functions are read through aliases of whole modules, local destructuring, bound functions and
|
|
35
|
+
nested conditional selections. Reassigning an alias to a helper no longer leaves it treated as a test.
|
|
36
|
+
- Runtime test contexts and their methods keep their meaning through chains of aliases, `bind()` and
|
|
37
|
+
default parameter values. Arguments supplied by `bind()` retain their condition and reason, and a
|
|
38
|
+
bound title no longer gives a diagnostic whose end is before its start.
|
|
39
|
+
- A runtime skip in a conditional loop, or after a conditional early exit inside a nested block, is
|
|
40
|
+
read as conditional. A TypeScript assertion around a literal boolean keeps that boolean's meaning.
|
|
41
|
+
- With the retry state on, Jest's retry calls are read through renamed and namespace imports, and a
|
|
42
|
+
Mocha describe chosen by a condition still supplies its suite context for `this.retries()`.
|
|
43
|
+
- Chains of constants retain their tags, details and annotation tickets. An unread computed property
|
|
44
|
+
no longer leaves an earlier member of a tag object treated as certain.
|
|
45
|
+
- Tags from literal and nested template interpolations are checked using the rendered text's boundaries,
|
|
46
|
+
so joining text around an `@` doesn't invent a state tag. Plain Vitest tags written as templates count
|
|
47
|
+
like quoted strings. Cyclic constant and template definitions remain unread instead of recursing.
|
|
48
|
+
- `annotations.only` checks every supplied issue ticket, including an invalid one next to a valid one.
|
|
49
|
+
- The commented-out-test rule recognizes renamed test imports.
|
|
50
|
+
- Removing an orphaned marker removes its complete line with CR or Unicode line endings too.
|
|
51
|
+
- A suggestion to add another state's marker no longer copies a ticket that state's format rejects.
|
|
52
|
+
- Suggestions that move a ticket into annotations escape line breaks in the annotation type or key,
|
|
53
|
+
keeping the suggested JavaScript valid.
|
|
54
|
+
|
|
9
55
|
## [0.13.2] - 2026-11-08
|
|
10
56
|
|
|
11
57
|
### Fixed
|
|
@@ -598,7 +644,8 @@ First release.
|
|
|
598
644
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
599
645
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
600
646
|
|
|
601
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.
|
|
647
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.3...HEAD
|
|
648
|
+
[0.13.3]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.2...v0.13.3
|
|
602
649
|
[0.13.2]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.1...v0.13.2
|
|
603
650
|
[0.13.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...v0.13.1
|
|
604
651
|
[0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
|
|
@@ -70,7 +70,10 @@ export default createRule({
|
|
|
70
70
|
state: state.name,
|
|
71
71
|
subject: subjectName,
|
|
72
72
|
},
|
|
73
|
-
suggest: present.comment.type === 'Line' &&
|
|
73
|
+
suggest: present.comment.type === 'Line' &&
|
|
74
|
+
tickets !== '' &&
|
|
75
|
+
present.tickets.every((ticket) => state.ticket.check(ticket.text) === 'ok') &&
|
|
76
|
+
directive?.kind !== 'eslint-disable-line'
|
|
74
77
|
? [
|
|
75
78
|
{
|
|
76
79
|
messageId: 'addMarker',
|
|
@@ -32,9 +32,9 @@ export default createRule({
|
|
|
32
32
|
context.report({ loc, messageId: 'missingMarker', data });
|
|
33
33
|
continue;
|
|
34
34
|
}
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
const
|
|
35
|
+
const broken = own.find((m) => m.tickets.length === 0 || m.tickets.some((t) => t.result !== 'ok'));
|
|
36
|
+
if (broken === undefined) {
|
|
37
|
+
const extra = own.find((marker) => marker.extra !== undefined)?.extra;
|
|
38
38
|
if (extra === undefined || options.allowNotes)
|
|
39
39
|
continue;
|
|
40
40
|
context.report({
|
|
@@ -51,7 +51,7 @@ export default createRule({
|
|
|
51
51
|
});
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
const
|
|
54
|
+
const marker = broken;
|
|
55
55
|
const bad = marker.tickets.find((t) => t.result !== 'ok');
|
|
56
56
|
if (bad === undefined) {
|
|
57
57
|
context.report({ loc: marker.comment.loc, messageId: 'missingTicket', data });
|
|
@@ -107,11 +107,6 @@ export default createRule({
|
|
|
107
107
|
reportReason(subject, state);
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
|
-
if (annotationOnly) {
|
|
111
|
-
reportAnnotationOnly(subject, state, annotationOnly);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
const result = evaluate(subject, state);
|
|
115
110
|
// Annotations are checked whenever a ticket is needed, whatever the markers say.
|
|
116
111
|
for (const ticket of annotationTicketsOf(subject)) {
|
|
117
112
|
if (state.ticket.check(ticket.text) !== 'ok' && reportOnce(`annotation:${ticket.node.range[0]}`)) {
|
|
@@ -122,6 +117,11 @@ export default createRule({
|
|
|
122
117
|
});
|
|
123
118
|
}
|
|
124
119
|
}
|
|
120
|
+
if (annotationOnly) {
|
|
121
|
+
reportAnnotationOnly(subject, state, annotationOnly);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const result = evaluate(subject, state);
|
|
125
125
|
if (result.kind === 'missing' && hasAnnotationTicket(subject, state, options))
|
|
126
126
|
continue;
|
|
127
127
|
if (result.kind !== 'ok' &&
|
package/dist/utils/analyze.js
CHANGED
|
@@ -99,17 +99,19 @@ function disableRegions(comments, states) {
|
|
|
99
99
|
}
|
|
100
100
|
/** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
|
|
101
101
|
function infoNames(param, methods, step) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const target = param?.type === 'AssignmentPattern' ? param.left : param;
|
|
103
|
+
if (target?.type === 'Identifier')
|
|
104
|
+
return [{ name: target.name, binding: target, methods, step }];
|
|
105
|
+
if (target?.type !== 'ObjectPattern')
|
|
105
106
|
return [];
|
|
106
|
-
return
|
|
107
|
-
if (prop.type !== 'Property'
|
|
107
|
+
return target.properties.flatMap((prop) => {
|
|
108
|
+
if (prop.type !== 'Property')
|
|
109
|
+
return [];
|
|
110
|
+
const value = prop.value.type === 'AssignmentPattern' ? prop.value.left : prop.value;
|
|
111
|
+
if (value.type !== 'Identifier')
|
|
108
112
|
return [];
|
|
109
113
|
const method = propertyName(prop);
|
|
110
|
-
return method !== undefined && methods.has(method)
|
|
111
|
-
? [{ name: prop.value.name, binding: prop.value, method, step }]
|
|
112
|
-
: [];
|
|
114
|
+
return method !== undefined && methods.has(method) ? [{ name: value.name, binding: value, method, step }] : [];
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
function memberName(node) {
|
|
@@ -133,14 +135,14 @@ function memberKey(node) {
|
|
|
133
135
|
function chainOf(node, factories) {
|
|
134
136
|
const path = [];
|
|
135
137
|
const args = new Map();
|
|
136
|
-
let current = node;
|
|
138
|
+
let current = unwrap(node);
|
|
137
139
|
for (;;) {
|
|
138
140
|
if (current.type === 'MemberExpression') {
|
|
139
141
|
const name = memberName(current);
|
|
140
142
|
if (name === undefined)
|
|
141
143
|
return undefined;
|
|
142
144
|
path.unshift(name);
|
|
143
|
-
current = current.object;
|
|
145
|
+
current = unwrap(current.object);
|
|
144
146
|
continue;
|
|
145
147
|
}
|
|
146
148
|
const factory = current.type === 'CallExpression'
|
|
@@ -154,9 +156,9 @@ function chainOf(node, factories) {
|
|
|
154
156
|
if (name === undefined || !factories.has(name))
|
|
155
157
|
return undefined;
|
|
156
158
|
path.unshift(`${name}()`);
|
|
157
|
-
if (current.type === 'CallExpression')
|
|
159
|
+
if (current.type === 'CallExpression' && !args.has(`${name}()`))
|
|
158
160
|
args.set(`${name}()`, current.arguments.at(0));
|
|
159
|
-
current = factory.object;
|
|
161
|
+
current = unwrap(factory.object);
|
|
160
162
|
}
|
|
161
163
|
return current.type === 'Identifier' || current.type === 'CallExpression' ? { root: current, path, args } : undefined;
|
|
162
164
|
}
|
|
@@ -164,8 +166,12 @@ function unwrap(node) {
|
|
|
164
166
|
let current = node;
|
|
165
167
|
while (current.type === 'TSAsExpression' ||
|
|
166
168
|
current.type === 'TSNonNullExpression' ||
|
|
167
|
-
current.type === 'TSSatisfiesExpression'
|
|
168
|
-
current
|
|
169
|
+
current.type === 'TSSatisfiesExpression' ||
|
|
170
|
+
current.type === 'TSTypeAssertion' ||
|
|
171
|
+
current.type === 'TSInstantiationExpression' ||
|
|
172
|
+
current.type === 'SequenceExpression') {
|
|
173
|
+
current =
|
|
174
|
+
current.type === 'SequenceExpression' ? current.expressions[current.expressions.length - 1] : current.expression;
|
|
169
175
|
}
|
|
170
176
|
return current;
|
|
171
177
|
}
|
|
@@ -382,6 +388,67 @@ function declaredVariable(sourceCode, id) {
|
|
|
382
388
|
}
|
|
383
389
|
return undefined;
|
|
384
390
|
}
|
|
391
|
+
const reassignedBindings = new WeakMap();
|
|
392
|
+
/** Whether the binding has writes beyond its initial declaration. */
|
|
393
|
+
function isReassigned(sourceCode, id) {
|
|
394
|
+
const variable = declaredVariable(sourceCode, id);
|
|
395
|
+
if (variable === undefined)
|
|
396
|
+
return false;
|
|
397
|
+
const cached = reassignedBindings.get(variable);
|
|
398
|
+
if (cached !== undefined)
|
|
399
|
+
return cached;
|
|
400
|
+
const written = variable.references.some((reference) => reference.isWrite() && !reference.init);
|
|
401
|
+
reassignedBindings.set(variable, written);
|
|
402
|
+
return written;
|
|
403
|
+
}
|
|
404
|
+
const mutatedBindings = new WeakMap();
|
|
405
|
+
const ARRAY_MUTATORS = new Set(['copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift']);
|
|
406
|
+
const OBJECT_MUTATORS = new Set(['assign', 'defineProperty', 'defineProperties']);
|
|
407
|
+
const REFLECT_MUTATORS = new Set(['set', 'deleteProperty', 'defineProperty']);
|
|
408
|
+
/** Whether a binding's object is written through a property, an array mutator or a local alias. */
|
|
409
|
+
function isMutated(sourceCode, id, seen = []) {
|
|
410
|
+
const variable = declaredVariable(sourceCode, id);
|
|
411
|
+
if (variable === undefined || seen.includes(variable))
|
|
412
|
+
return false;
|
|
413
|
+
const cached = mutatedBindings.get(variable);
|
|
414
|
+
if (cached !== undefined)
|
|
415
|
+
return cached;
|
|
416
|
+
const written = variable.references.some((reference) => {
|
|
417
|
+
let value = reference.identifier;
|
|
418
|
+
let parent = value.parent;
|
|
419
|
+
while (parent.type !== 'Program' &&
|
|
420
|
+
((parent.type === 'MemberExpression' && parent.object === value) || unwrap(parent) === value)) {
|
|
421
|
+
value = parent;
|
|
422
|
+
parent = value.parent;
|
|
423
|
+
}
|
|
424
|
+
if (parent.type === 'VariableDeclarator' && parent.init === value && parent.id.type === 'Identifier') {
|
|
425
|
+
return isMutated(sourceCode, parent.id, [...seen, variable]);
|
|
426
|
+
}
|
|
427
|
+
if (parent.type === 'CallExpression' && parent.arguments[0] === value) {
|
|
428
|
+
const callee = unwrap(parent.callee);
|
|
429
|
+
if (callee.type === 'MemberExpression' &&
|
|
430
|
+
callee.object.type === 'Identifier' &&
|
|
431
|
+
declaredWhere(sourceCode, callee.object) === 'global') {
|
|
432
|
+
const methods = callee.object.name === 'Object'
|
|
433
|
+
? OBJECT_MUTATORS
|
|
434
|
+
: callee.object.name === 'Reflect'
|
|
435
|
+
? REFLECT_MUTATORS
|
|
436
|
+
: undefined;
|
|
437
|
+
if (methods?.has(memberName(callee) ?? ''))
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const member = unwrap(value);
|
|
442
|
+
if (member.type !== 'MemberExpression')
|
|
443
|
+
return false;
|
|
444
|
+
return ((parent.type === 'AssignmentExpression' && parent.left === value) ||
|
|
445
|
+
parent.type === 'UpdateExpression' ||
|
|
446
|
+
(parent.type === 'UnaryExpression' && parent.operator === 'delete') ||
|
|
447
|
+
(parent.type === 'CallExpression' && parent.callee === value && ARRAY_MUTATORS.has(memberName(member) ?? '')));
|
|
448
|
+
});
|
|
449
|
+
mutatedBindings.set(variable, written);
|
|
450
|
+
return written;
|
|
451
|
+
}
|
|
385
452
|
/**
|
|
386
453
|
* Whether `id` refers to a variable or function declared inside a function or block, such as a local
|
|
387
454
|
* helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
|
|
@@ -551,33 +618,66 @@ function parseMarkers(comment, states) {
|
|
|
551
618
|
}
|
|
552
619
|
return markers;
|
|
553
620
|
}
|
|
554
|
-
|
|
621
|
+
/** Rendered template text and the offsets of its alternating literal and expression parts. */
|
|
622
|
+
function templateText(sourceCode, node) {
|
|
623
|
+
const parts = node.quasis.flatMap((quasi, i) => [
|
|
624
|
+
// `cooked` is null only in tagged templates, which are not read as title or tag text.
|
|
625
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
626
|
+
quasi.value.cooked,
|
|
627
|
+
...(i < node.expressions.length ? [interpolationText(sourceCode, node.expressions[i]) ?? ' '] : []),
|
|
628
|
+
]);
|
|
629
|
+
let offset = 0;
|
|
630
|
+
const offsets = parts.map((part) => {
|
|
631
|
+
const start = offset;
|
|
632
|
+
offset += part.length;
|
|
633
|
+
return start;
|
|
634
|
+
});
|
|
635
|
+
return { parts, text: parts.join(''), offsets };
|
|
636
|
+
}
|
|
637
|
+
function scanTags(sourceCode, found, out) {
|
|
638
|
+
const { text: node, via, before = '', after = '' } = found;
|
|
639
|
+
const source = sourceCode.text;
|
|
555
640
|
// Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
: node.
|
|
641
|
+
let parts;
|
|
642
|
+
if (node.type === 'Literal')
|
|
643
|
+
parts = [{ text: node.value, start: node.range[0] + 1, before, after }];
|
|
644
|
+
else {
|
|
645
|
+
const rendered = templateText(sourceCode, node);
|
|
646
|
+
parts = node.quasis.map((quasi, i) => ({
|
|
559
647
|
// `cooked` is only null in tagged templates, which are never titles.
|
|
560
648
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
561
649
|
text: quasi.value.cooked,
|
|
562
650
|
start: quasi.range[0] + 1,
|
|
651
|
+
before: rendered.offsets[i * 2] === 0 ? before : rendered.text.charAt(rendered.offsets[i * 2] - 1),
|
|
652
|
+
after: rendered.text.slice(rendered.offsets[i * 2] + rendered.parts[i * 2].length) + after,
|
|
563
653
|
}));
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
654
|
+
}
|
|
655
|
+
for (const { text, start, before, after } of parts) {
|
|
656
|
+
const prefix = before.slice(-1);
|
|
657
|
+
const tagStart = text.lastIndexOf('@');
|
|
658
|
+
const continuation = tagStart >= 0 && /^[\w-]*$/.test(text.slice(tagStart + 1)) ? (/^[\w-]+/.exec(after)?.[0] ?? '') : '';
|
|
659
|
+
for (const match of (prefix + text + continuation).matchAll(TAG_RE)) {
|
|
660
|
+
const index = match.index - prefix.length;
|
|
661
|
+
if (index < 0 || index >= text.length)
|
|
662
|
+
continue;
|
|
663
|
+
const end = index + match[0].length;
|
|
664
|
+
const written = via === undefined && end <= text.length && source.slice(start, start + end) === text.slice(0, end);
|
|
665
|
+
out.push({ tag: match[0], node: via ?? node, ...(written && { range: [start + index, start + end] }) });
|
|
569
666
|
}
|
|
570
667
|
}
|
|
571
668
|
}
|
|
572
669
|
const PLAIN_TAG_RE = /^[\w-]+$/;
|
|
573
670
|
/** A Vitest tag name such as `'flaky'` in `tags`, which stands for the `@flaky` tag. */
|
|
574
|
-
function plainTag(
|
|
575
|
-
if (
|
|
671
|
+
function plainTag(sourceCode, { text, via, embedded }) {
|
|
672
|
+
if (embedded)
|
|
673
|
+
return undefined;
|
|
674
|
+
const value = staticText(sourceCode, text);
|
|
675
|
+
if (value === undefined || !PLAIN_TAG_RE.test(value))
|
|
576
676
|
return undefined;
|
|
577
677
|
const start = text.range[0] + 1;
|
|
578
|
-
const end = start +
|
|
579
|
-
const written = via === undefined &&
|
|
580
|
-
return { tag: `@${
|
|
678
|
+
const end = start + value.length;
|
|
679
|
+
const written = via === undefined && sourceCode.text.slice(start, end) === value;
|
|
680
|
+
return { tag: `@${value}`, node: via ?? text, plain: true, ...(written && { range: [start, end] }) };
|
|
581
681
|
}
|
|
582
682
|
function isStaticText(node) {
|
|
583
683
|
return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
|
|
@@ -587,6 +687,8 @@ function isStaticText(node) {
|
|
|
587
687
|
* or the enum itself. Undefined for any other name, such as an import, a parameter or a `let`.
|
|
588
688
|
*/
|
|
589
689
|
function declaredValue(sourceCode, id) {
|
|
690
|
+
if (isMutated(sourceCode, id))
|
|
691
|
+
return undefined;
|
|
590
692
|
for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
|
|
591
693
|
const variable = scope.set.get(id.name);
|
|
592
694
|
if (!variable)
|
|
@@ -602,13 +704,16 @@ function declaredValue(sourceCode, id) {
|
|
|
602
704
|
}
|
|
603
705
|
return undefined;
|
|
604
706
|
}
|
|
707
|
+
function isPrototypeSetter(prop) {
|
|
708
|
+
return (!prop.computed && !prop.shorthand && !prop.method && prop.kind === 'init' && propertyName(prop) === '__proto__');
|
|
709
|
+
}
|
|
605
710
|
/** The value of the last property called `key`, or undefined when a spread written after it may replace it. */
|
|
606
711
|
function propertyValue(object, key) {
|
|
607
712
|
for (let i = object.properties.length - 1; i >= 0; i--) {
|
|
608
713
|
const prop = object.properties[i];
|
|
609
|
-
if (prop.type === 'SpreadElement')
|
|
714
|
+
if (prop.type === 'SpreadElement' || (prop.computed && propertyName(prop) === undefined))
|
|
610
715
|
return undefined;
|
|
611
|
-
if (propertyName(prop) === key)
|
|
716
|
+
if (!isPrototypeSetter(prop) && propertyName(prop) === key)
|
|
612
717
|
return prop.value;
|
|
613
718
|
}
|
|
614
719
|
return undefined;
|
|
@@ -618,18 +723,32 @@ function propertyValue(object, key) {
|
|
|
618
723
|
* after `const NEW = '@new'`, or of `Tags.NEW` after `const Tags = { NEW: '@new' }` or
|
|
619
724
|
* `enum Tags { NEW = '@new' }`.
|
|
620
725
|
*/
|
|
621
|
-
function constantValue(sourceCode, node) {
|
|
726
|
+
function constantValue(sourceCode, node, seen = []) {
|
|
727
|
+
if (seen.includes(node))
|
|
728
|
+
return undefined;
|
|
729
|
+
const path = [...seen, node];
|
|
730
|
+
const follow = (value) => {
|
|
731
|
+
if (value === undefined)
|
|
732
|
+
return undefined;
|
|
733
|
+
const target = unwrap(value);
|
|
734
|
+
return target.type === 'Identifier' || target.type === 'MemberExpression'
|
|
735
|
+
? constantValue(sourceCode, target, path)
|
|
736
|
+
: target;
|
|
737
|
+
};
|
|
622
738
|
if (node.type === 'Identifier')
|
|
623
|
-
return declaredValue(sourceCode, node);
|
|
739
|
+
return follow(declaredValue(sourceCode, node));
|
|
624
740
|
if (node.type !== 'MemberExpression')
|
|
625
741
|
return undefined;
|
|
626
742
|
const key = memberKey(node);
|
|
627
|
-
const
|
|
743
|
+
const object = unwrap(node.object);
|
|
744
|
+
const value = object.type === 'ObjectExpression' || object.type === 'ArrayExpression'
|
|
745
|
+
? object
|
|
746
|
+
: constantValue(sourceCode, object, path);
|
|
628
747
|
const owner = value && unwrap(value);
|
|
629
748
|
if (key === undefined || owner === undefined)
|
|
630
749
|
return undefined;
|
|
631
750
|
if (owner.type === 'ObjectExpression')
|
|
632
|
-
return propertyValue(owner, key);
|
|
751
|
+
return follow(propertyValue(owner, key));
|
|
633
752
|
if (owner.type === 'ArrayExpression') {
|
|
634
753
|
const index = Number(key);
|
|
635
754
|
if (!Number.isInteger(index) || index < 0 || String(index) !== key)
|
|
@@ -637,29 +756,43 @@ function constantValue(sourceCode, node) {
|
|
|
637
756
|
if (owner.elements.slice(0, index + 1).some((element) => element?.type === 'SpreadElement'))
|
|
638
757
|
return undefined;
|
|
639
758
|
const element = owner.elements.at(index);
|
|
640
|
-
return element !== undefined && element !== null && element.type !== 'SpreadElement' ? element : undefined;
|
|
759
|
+
return element !== undefined && element !== null && element.type !== 'SpreadElement' ? follow(element) : undefined;
|
|
641
760
|
}
|
|
642
761
|
if (owner.type !== 'TSEnumDeclaration')
|
|
643
762
|
return undefined;
|
|
644
763
|
const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
|
|
645
|
-
return member?.initializer;
|
|
764
|
+
return follow(member?.initializer);
|
|
646
765
|
}
|
|
647
766
|
/**
|
|
648
767
|
* Reads the text a title or tag value is made of: strings, template literals and arrays of them,
|
|
649
768
|
* following constants declared in this file. What can't be read, such as a name imported from another
|
|
650
769
|
* file, goes in `unread`: the name the call uses, or the value itself.
|
|
651
770
|
*/
|
|
652
|
-
function readTexts(sourceCode, node, out, via, path = []) {
|
|
771
|
+
function readTexts(sourceCode, node, out, via, path = [], context = {}) {
|
|
653
772
|
const target = unwrap(node);
|
|
773
|
+
if (path.includes(target)) {
|
|
774
|
+
out.unread.add(via ?? target);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
654
777
|
if (isStaticText(target)) {
|
|
655
|
-
out.texts.push({ text: target, via });
|
|
778
|
+
out.texts.push({ text: target, via, ...context });
|
|
656
779
|
// A template that holds a constant, as in `` `${TAG}` ``, reads the constant's text too.
|
|
657
780
|
if (target.type === 'TemplateLiteral') {
|
|
658
|
-
|
|
659
|
-
|
|
781
|
+
const rendered = templateText(sourceCode, target);
|
|
782
|
+
for (const [i, expression] of target.expressions.entries()) {
|
|
783
|
+
const own = unwrap(expression);
|
|
784
|
+
const value = isStaticText(own) ? own : constantValue(sourceCode, own);
|
|
660
785
|
const resolved = value && unwrap(value);
|
|
661
|
-
if (resolved !== undefined && isStaticText(resolved))
|
|
662
|
-
|
|
786
|
+
if (resolved !== undefined && isStaticText(resolved)) {
|
|
787
|
+
readTexts(sourceCode, resolved, out, via, [...path, target], {
|
|
788
|
+
before: rendered.offsets[i * 2 + 1] === 0
|
|
789
|
+
? (context.before ?? '')
|
|
790
|
+
: rendered.text.charAt(rendered.offsets[i * 2 + 1] - 1),
|
|
791
|
+
after: rendered.text.slice(rendered.offsets[i * 2 + 1] + rendered.parts[i * 2 + 1].length) +
|
|
792
|
+
(context.after ?? ''),
|
|
793
|
+
embedded: true,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
663
796
|
}
|
|
664
797
|
}
|
|
665
798
|
return;
|
|
@@ -667,7 +800,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
|
|
|
667
800
|
if (target.type === 'ArrayExpression') {
|
|
668
801
|
for (const item of target.elements) {
|
|
669
802
|
if (item)
|
|
670
|
-
readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, path);
|
|
803
|
+
readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, [...path, target], context);
|
|
671
804
|
}
|
|
672
805
|
return;
|
|
673
806
|
}
|
|
@@ -676,7 +809,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
|
|
|
676
809
|
if (value === undefined || path.includes(value))
|
|
677
810
|
out.unread.add(via ?? target);
|
|
678
811
|
else
|
|
679
|
-
readTexts(sourceCode, value, out, via ?? target, [...path,
|
|
812
|
+
readTexts(sourceCode, value, out, via ?? target, [...path, target], context);
|
|
680
813
|
}
|
|
681
814
|
/**
|
|
682
815
|
* The key of a details property, following a computed string key declared in this file. A computed key that
|
|
@@ -726,6 +859,8 @@ function objectDetails(sourceCode, object, unread, path = []) {
|
|
|
726
859
|
entries.set(key, value);
|
|
727
860
|
continue;
|
|
728
861
|
}
|
|
862
|
+
if (isPrototypeSetter(prop))
|
|
863
|
+
continue;
|
|
729
864
|
const key = detailKey(sourceCode, prop, unread);
|
|
730
865
|
if (key === undefined)
|
|
731
866
|
entries.clear();
|
|
@@ -755,16 +890,22 @@ function detailsOf(sourceCode, call, optionsFirst) {
|
|
|
755
890
|
const unread = [];
|
|
756
891
|
return { entries: objectDetails(sourceCode, object, unread), unread };
|
|
757
892
|
}
|
|
758
|
-
/**
|
|
759
|
-
function staticText(sourceCode, node) {
|
|
893
|
+
/** Static string text, including templates whose expressions resolve to strings in this file. */
|
|
894
|
+
function staticText(sourceCode, node, seen = []) {
|
|
895
|
+
if (seen.includes(node))
|
|
896
|
+
return undefined;
|
|
897
|
+
const path = [...seen, node];
|
|
760
898
|
const read = (value) => {
|
|
761
899
|
if (value.type === 'Literal')
|
|
762
900
|
return typeof value.value === 'string' ? value.value : undefined;
|
|
763
|
-
if (value.type !== 'TemplateLiteral'
|
|
901
|
+
if (value.type !== 'TemplateLiteral')
|
|
902
|
+
return undefined;
|
|
903
|
+
const expressions = value.expressions.map((expression) => interpolationText(sourceCode, expression, path));
|
|
904
|
+
if (expressions.some((text) => text === undefined))
|
|
764
905
|
return undefined;
|
|
765
906
|
// `cooked` is only null in tagged templates, which no annotation value is.
|
|
766
907
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
767
|
-
return value.quasis
|
|
908
|
+
return value.quasis.map((quasi, i) => quasi.value.cooked + (expressions[i] ?? '')).join('');
|
|
768
909
|
};
|
|
769
910
|
const target = unwrap(node);
|
|
770
911
|
const own = read(target);
|
|
@@ -773,6 +914,31 @@ function staticText(sourceCode, node) {
|
|
|
773
914
|
const constant = constantValue(sourceCode, target);
|
|
774
915
|
return constant === undefined ? undefined : read(unwrap(constant));
|
|
775
916
|
}
|
|
917
|
+
/** Static primitive interpolation text, without evaluating object coercion or user code. */
|
|
918
|
+
function interpolationText(sourceCode, node, seen = []) {
|
|
919
|
+
const text = staticText(sourceCode, node, seen);
|
|
920
|
+
if (text !== undefined)
|
|
921
|
+
return text;
|
|
922
|
+
const target = unwrap(node);
|
|
923
|
+
const value = constantValue(sourceCode, target) ?? target;
|
|
924
|
+
if (value.type === 'Identifier' &&
|
|
925
|
+
declaredWhere(sourceCode, value) === 'global' &&
|
|
926
|
+
['undefined', 'NaN', 'Infinity'].includes(value.name))
|
|
927
|
+
return value.name;
|
|
928
|
+
if (value.type === 'UnaryExpression') {
|
|
929
|
+
if (value.operator === 'void')
|
|
930
|
+
return 'undefined';
|
|
931
|
+
const argument = unwrap(value.argument);
|
|
932
|
+
if (value.operator === '-' &&
|
|
933
|
+
argument.type === 'Literal' &&
|
|
934
|
+
(typeof argument.value === 'number' || typeof argument.value === 'bigint'))
|
|
935
|
+
return String(-argument.value);
|
|
936
|
+
}
|
|
937
|
+
if (value.type !== 'Literal' ||
|
|
938
|
+
(value.value !== null && !['number', 'boolean', 'bigint'].includes(typeof value.value)))
|
|
939
|
+
return undefined;
|
|
940
|
+
return String(value.value);
|
|
941
|
+
}
|
|
776
942
|
/**
|
|
777
943
|
* The object or array a details value is, following a constant declared in this file: a carrier for
|
|
778
944
|
* annotations. Undefined for a value that plainly can't hold one, `'unread'` for one that can't be read.
|
|
@@ -887,25 +1053,24 @@ const NO_TAGS = {
|
|
|
887
1053
|
reasonedOptions: new Set(),
|
|
888
1054
|
};
|
|
889
1055
|
function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
890
|
-
const source = sourceCode.text;
|
|
891
1056
|
const tags = [];
|
|
892
1057
|
const title = { texts: [], unread: new Set() };
|
|
893
1058
|
// node:test's `test({ tags: ['db'] }, fn)` has no title, only options.
|
|
894
1059
|
if (hasTitle)
|
|
895
1060
|
readTexts(sourceCode, call.arguments[0], title);
|
|
896
1061
|
for (const found of title.texts)
|
|
897
|
-
scanTags(
|
|
1062
|
+
scanTags(sourceCode, found, tags);
|
|
898
1063
|
const values = { texts: [], unread: new Set() };
|
|
899
1064
|
for (const [key, value] of details.entries) {
|
|
900
1065
|
if (key === framework.tagKey)
|
|
901
1066
|
readTexts(sourceCode, value, values);
|
|
902
1067
|
}
|
|
903
1068
|
for (const found of values.texts) {
|
|
904
|
-
const plain = framework.plainTags ? plainTag(
|
|
1069
|
+
const plain = framework.plainTags ? plainTag(sourceCode, found) : undefined;
|
|
905
1070
|
if (plain)
|
|
906
1071
|
tags.push(plain);
|
|
907
1072
|
else
|
|
908
|
-
scanTags(
|
|
1073
|
+
scanTags(sourceCode, found, tags);
|
|
909
1074
|
}
|
|
910
1075
|
const unread = [...values.unread];
|
|
911
1076
|
if (details.unread.length > 0 && framework.tagKey !== undefined)
|
|
@@ -977,7 +1142,8 @@ function thisFunction(node) {
|
|
|
977
1142
|
}
|
|
978
1143
|
/** The value of a `true` or `false` literal; undefined for anything else. */
|
|
979
1144
|
function booleanOf(node) {
|
|
980
|
-
|
|
1145
|
+
const value = node === undefined ? undefined : unwrap(node);
|
|
1146
|
+
return value?.type === 'Literal' && typeof value.value === 'boolean' ? value.value : undefined;
|
|
981
1147
|
}
|
|
982
1148
|
/**
|
|
983
1149
|
* Whether a node:test option applies: Node counts any value but `false` and `undefined`, so a reason such
|
|
@@ -994,23 +1160,31 @@ function optionApplies(node) {
|
|
|
994
1160
|
}
|
|
995
1161
|
/** Keys of a retry options object that hold a count: Vitest's `count`, Cypress's `runMode` and `openMode`. */
|
|
996
1162
|
const RETRY_COUNT_KEYS = new Set(['count', 'runMode', 'openMode']);
|
|
1163
|
+
const VITEST_RETRY_COUNT_KEYS = new Set(['count']);
|
|
1164
|
+
const CYPRESS_RETRY_COUNT_KEYS = new Set(['runMode', 'openMode']);
|
|
997
1165
|
/**
|
|
998
1166
|
* Whether a retry count gives retries: anything but `0`, `false`, `null` or `undefined` written out, since a
|
|
999
1167
|
* count known only when the tests run, as `process.env.CI ? 2 : 0`, retries where flaky tests hide. An
|
|
1000
1168
|
* object retries unless every count it holds is one of those.
|
|
1001
1169
|
*/
|
|
1002
|
-
function retries(node) {
|
|
1170
|
+
function retries(sourceCode, node, keys = RETRY_COUNT_KEYS, seen = []) {
|
|
1003
1171
|
if (node === undefined)
|
|
1004
1172
|
return false;
|
|
1005
|
-
|
|
1173
|
+
if (seen.includes(node))
|
|
1174
|
+
return true;
|
|
1175
|
+
const own = unwrap(node);
|
|
1176
|
+
const value = constantValue(sourceCode, own) ?? own;
|
|
1006
1177
|
if (value.type === 'Literal')
|
|
1007
1178
|
return value.value !== 0 && value.value !== false && value.value !== null;
|
|
1008
1179
|
if (value.type === 'Identifier')
|
|
1009
1180
|
return value.name !== 'undefined';
|
|
1010
1181
|
if (value.type !== 'ObjectExpression')
|
|
1011
1182
|
return true;
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1183
|
+
const unread = [];
|
|
1184
|
+
const counts = objectDetails(sourceCode, value, unread).filter(([key]) => keys.has(key));
|
|
1185
|
+
if (unread.length > 0 && ![...keys].every((key) => counts.some(([name]) => name === key)))
|
|
1186
|
+
return true;
|
|
1187
|
+
return counts.length === 0 || counts.some(([, count]) => retries(sourceCode, count, keys, [...seen, node]));
|
|
1014
1188
|
}
|
|
1015
1189
|
/** Values that are never `false` or `undefined`. */
|
|
1016
1190
|
const OPTION_VALUES = new Set([
|
|
@@ -1026,13 +1200,13 @@ const OPTION_VALUES = new Set([
|
|
|
1026
1200
|
* Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
|
|
1027
1201
|
* Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
|
|
1028
1202
|
*/
|
|
1029
|
-
function isUnconditional(call, framework) {
|
|
1203
|
+
function isUnconditional(sourceCode, call, framework) {
|
|
1030
1204
|
const first = call.arguments.at(0);
|
|
1031
1205
|
const always = first === undefined ||
|
|
1032
1206
|
booleanOf(first) === true ||
|
|
1033
1207
|
// node:test's `t.skip(message)` and Jasmine's `pending(message)` take no condition, so any argument is a note.
|
|
1034
1208
|
framework.reasonArgument === 'only' ||
|
|
1035
|
-
(framework.skipNotes && isStaticText(first));
|
|
1209
|
+
(framework.skipNotes && (isStaticText(unwrap(first)) || staticText(sourceCode, first) !== undefined));
|
|
1036
1210
|
return always && !isGuarded(call);
|
|
1037
1211
|
}
|
|
1038
1212
|
/**
|
|
@@ -1087,19 +1261,26 @@ function optionReason(sourceCode, node) {
|
|
|
1087
1261
|
return isReasonText(sourceCode, value.consequent) || isReasonText(sourceCode, value.alternate);
|
|
1088
1262
|
}
|
|
1089
1263
|
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
1090
|
-
/** Whether a statement
|
|
1264
|
+
/** Whether a statement can leave the enclosing body, without entering nested functions or loops. */
|
|
1091
1265
|
function exits(statement) {
|
|
1092
|
-
if (statement.type === 'ReturnStatement' ||
|
|
1266
|
+
if (statement.type === 'ReturnStatement' ||
|
|
1267
|
+
statement.type === 'ThrowStatement' ||
|
|
1268
|
+
statement.type === 'BreakStatement' ||
|
|
1269
|
+
statement.type === 'ContinueStatement')
|
|
1093
1270
|
return true;
|
|
1271
|
+
if (statement.type === 'IfStatement')
|
|
1272
|
+
return exits(statement.consequent) || (statement.alternate !== null && exits(statement.alternate));
|
|
1094
1273
|
return statement.type === 'BlockStatement' && statement.body.some(exits);
|
|
1095
1274
|
}
|
|
1096
1275
|
/** `if (ready) return;` or `if (!ok) { throw error; }`: code after it only runs on some paths. */
|
|
1097
1276
|
function isEarlyExit(statement) {
|
|
1277
|
+
if (statement.type === 'BlockStatement')
|
|
1278
|
+
return statement.body.some(isEarlyExit);
|
|
1098
1279
|
return (statement.type === 'IfStatement' &&
|
|
1099
1280
|
(exits(statement.consequent) || (statement.alternate !== null && exits(statement.alternate))));
|
|
1100
1281
|
}
|
|
1101
1282
|
/**
|
|
1102
|
-
* Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary,
|
|
1283
|
+
* Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary or a loop,
|
|
1103
1284
|
* the right side of `&&`, `||` or `??`, in a `catch` block, after other code in a `try` block that has a
|
|
1104
1285
|
* `catch`, or after an early exit such as `if (ready) return;`. `if (!enabled) test.skip()` is the same as `test.skip(!enabled)`, and
|
|
1105
1286
|
* `catch { test.skip(true, 'service is down') }` only skips when the service is down.
|
|
@@ -1107,11 +1288,18 @@ function isEarlyExit(statement) {
|
|
|
1107
1288
|
function isGuarded(call) {
|
|
1108
1289
|
let child = call;
|
|
1109
1290
|
for (let node = call.parent; node && !FUNCTIONS.has(node.type); child = node, node = node.parent) {
|
|
1110
|
-
|
|
1291
|
+
const same = (other) => child.range[0] === other.range[0] && child.range[1] === other.range[1];
|
|
1292
|
+
if ((node.type === 'IfStatement' || node.type === 'ConditionalExpression') && !same(node.test))
|
|
1293
|
+
return true;
|
|
1294
|
+
if (node.type === 'SwitchCase' && (node.test === null || !same(node.test)))
|
|
1111
1295
|
return true;
|
|
1112
|
-
if (node.type === '
|
|
1296
|
+
if (node.type === 'LogicalExpression' && same(node.right))
|
|
1113
1297
|
return true;
|
|
1114
|
-
if (node.type === '
|
|
1298
|
+
if (((node.type === 'WhileStatement' || node.type === 'ForStatement') &&
|
|
1299
|
+
node.test !== null &&
|
|
1300
|
+
booleanOf(node.test) !== true &&
|
|
1301
|
+
child === node.body) ||
|
|
1302
|
+
((node.type === 'ForInStatement' || node.type === 'ForOfStatement') && child === node.body))
|
|
1115
1303
|
return true;
|
|
1116
1304
|
if (node.type === 'CatchClause')
|
|
1117
1305
|
return true;
|
|
@@ -1156,6 +1344,22 @@ export function analyze(sourceCode, options) {
|
|
|
1156
1344
|
function runAnalysis(sourceCode, options) {
|
|
1157
1345
|
const { states, framework } = options;
|
|
1158
1346
|
const testNames = collectTestNames(sourceCode.ast, framework, options.testFunctions);
|
|
1347
|
+
/** Whole-module imports or requires, including constant aliases of them. */
|
|
1348
|
+
const moduleOf = (node, seen = []) => {
|
|
1349
|
+
const value = unwrap(node);
|
|
1350
|
+
if (seen.includes(value))
|
|
1351
|
+
return false;
|
|
1352
|
+
if (value.type === 'Identifier') {
|
|
1353
|
+
if (isReassigned(sourceCode, value))
|
|
1354
|
+
return false;
|
|
1355
|
+
if (testNames.modules.has(value.name) && declaredWhere(sourceCode, value) === 'top')
|
|
1356
|
+
return true;
|
|
1357
|
+
const alias = declaredValue(sourceCode, value);
|
|
1358
|
+
return alias !== undefined && moduleOf(alias, [...seen, value]);
|
|
1359
|
+
}
|
|
1360
|
+
const source = requireSource(value);
|
|
1361
|
+
return source !== undefined && framework.ownsModule(source);
|
|
1362
|
+
};
|
|
1159
1363
|
/**
|
|
1160
1364
|
* The test function a callee starts from and the members after it, such as `test` and
|
|
1161
1365
|
* `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
|
|
@@ -1173,11 +1377,13 @@ function runAnalysis(sourceCode, options) {
|
|
|
1173
1377
|
const exported = framework.exports.get(path[0]);
|
|
1174
1378
|
return exported === undefined ? undefined : { root: exported, path: path.slice(1), args };
|
|
1175
1379
|
}
|
|
1380
|
+
if (isReassigned(sourceCode, root))
|
|
1381
|
+
return undefined;
|
|
1176
1382
|
let base = testNames.roots.get(root.name);
|
|
1177
1383
|
let rest = path;
|
|
1178
1384
|
// `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test` even when
|
|
1179
1385
|
// `test` was imported by name.
|
|
1180
|
-
const moduleLike =
|
|
1386
|
+
const moduleLike = moduleOf(root) || (framework.defaultExport !== undefined && base === framework.defaultExport);
|
|
1181
1387
|
const member = moduleLike ? framework.exports.get(path[0]) : undefined;
|
|
1182
1388
|
if (member !== undefined || base === undefined) {
|
|
1183
1389
|
base = member;
|
|
@@ -1185,6 +1391,28 @@ function runAnalysis(sourceCode, options) {
|
|
|
1185
1391
|
}
|
|
1186
1392
|
if (base !== undefined && !isLocalVariable(sourceCode, root))
|
|
1187
1393
|
return { root: base, path: rest, args };
|
|
1394
|
+
const definition = declaredVariable(sourceCode, root)?.defs.find((def) => def.type === 'Variable');
|
|
1395
|
+
const declarator = definition?.node;
|
|
1396
|
+
if (declarator?.type === 'VariableDeclarator' &&
|
|
1397
|
+
declarator.id.type === 'ObjectPattern' &&
|
|
1398
|
+
declarator.init !== null) {
|
|
1399
|
+
if (seen.includes(declarator))
|
|
1400
|
+
return undefined;
|
|
1401
|
+
const property = declarator.id.properties.find((prop) => {
|
|
1402
|
+
if (prop.type !== 'Property')
|
|
1403
|
+
return false;
|
|
1404
|
+
const binding = prop.value.type === 'AssignmentPattern' ? prop.value.left : prop.value;
|
|
1405
|
+
return binding.type === 'Identifier' && binding.name === root.name;
|
|
1406
|
+
});
|
|
1407
|
+
const key = property?.type === 'Property' ? propertyName(property) : undefined;
|
|
1408
|
+
const init = chainOf(unwrap(declarator.init), framework.factories);
|
|
1409
|
+
return key === undefined || init === undefined
|
|
1410
|
+
? undefined
|
|
1411
|
+
: target({ ...init, path: [...init.path, key, ...path], args: new Map([...init.args, ...args]) }, [
|
|
1412
|
+
...seen,
|
|
1413
|
+
declarator,
|
|
1414
|
+
]);
|
|
1415
|
+
}
|
|
1188
1416
|
const value = declaredValue(sourceCode, root);
|
|
1189
1417
|
if (value === undefined || seen.includes(value))
|
|
1190
1418
|
return undefined;
|
|
@@ -1197,6 +1425,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1197
1425
|
const allComments = sourceCode.getAllComments();
|
|
1198
1426
|
const commentedOut = findCommentedOutTests(sourceCode, allComments, new Set([
|
|
1199
1427
|
...options.testFunctions,
|
|
1428
|
+
...[...testNames.roots].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
|
|
1200
1429
|
...[...framework.exports, ...framework.globals].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
|
|
1201
1430
|
]), new Set([...framework.testMembers, ...framework.describeMembers, 'describe', 'step'].map((m) => m.replace('()', ''))), states);
|
|
1202
1431
|
const comments = allComments.filter((comment) => !commentedOut.comments.has(comment));
|
|
@@ -1371,14 +1600,16 @@ function runAnalysis(sourceCode, options) {
|
|
|
1371
1600
|
// `skipIf(true)` always skips and `skipIf(false)` never does, like `{ skip: true }` and `{ skip: false }`.
|
|
1372
1601
|
const condition = booleanOf(args.get(member));
|
|
1373
1602
|
if (condition === undefined)
|
|
1374
|
-
|
|
1603
|
+
modifiers.set(effect.modifier, false);
|
|
1375
1604
|
else if (condition === effect.when)
|
|
1376
|
-
|
|
1605
|
+
modifiers.set(effect.modifier, true);
|
|
1606
|
+
else
|
|
1607
|
+
modifiers.delete(effect.modifier);
|
|
1377
1608
|
}
|
|
1378
1609
|
// `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
|
|
1379
1610
|
for (const [key, value] of details) {
|
|
1380
1611
|
if (key === framework.retryOption) {
|
|
1381
|
-
if (retries(value))
|
|
1612
|
+
if (retries(sourceCode, value, framework.name === 'vitest' ? VITEST_RETRY_COUNT_KEYS : CYPRESS_RETRY_COUNT_KEYS))
|
|
1382
1613
|
add('retry', true);
|
|
1383
1614
|
continue;
|
|
1384
1615
|
}
|
|
@@ -1463,26 +1694,38 @@ function runAnalysis(sourceCode, options) {
|
|
|
1463
1694
|
const callee = unwrap(picker);
|
|
1464
1695
|
if (callee.type !== 'ConditionalExpression')
|
|
1465
1696
|
return undefined;
|
|
1466
|
-
const
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1697
|
+
const choices = (node) => {
|
|
1698
|
+
const value = unwrap(node);
|
|
1699
|
+
if (value.type === 'ConditionalExpression') {
|
|
1700
|
+
const yes = choices(value.consequent);
|
|
1701
|
+
const no = choices(value.alternate);
|
|
1702
|
+
return yes === undefined || no === undefined ? undefined : [...yes, ...no];
|
|
1703
|
+
}
|
|
1704
|
+
const picked = target(chainOf(value, framework.factories));
|
|
1705
|
+
const declaration = picked && declarationOf(picked);
|
|
1706
|
+
return declaration && [declaration];
|
|
1707
|
+
};
|
|
1708
|
+
const alternatives = choices(callee);
|
|
1470
1709
|
const args = call.arguments;
|
|
1471
|
-
if (
|
|
1710
|
+
if (alternatives === undefined)
|
|
1472
1711
|
return undefined;
|
|
1473
|
-
|
|
1712
|
+
const [first] = alternatives;
|
|
1713
|
+
if (!alternatives.every((choice) => choice.kind === first.kind && declares(choice, args)))
|
|
1474
1714
|
return undefined;
|
|
1475
1715
|
usesTest = true;
|
|
1476
1716
|
const { details, tagInfo } = headOf(call);
|
|
1477
|
-
const
|
|
1478
|
-
if (
|
|
1717
|
+
const branches = alternatives.map(({ members, args: conditions }) => declarationModifiers(first.kind, args, modifiersOf(members, details.entries, conditions)));
|
|
1718
|
+
if (first.kind === 'test')
|
|
1479
1719
|
addThisBody(args);
|
|
1720
|
+
const body = args.findLast(isFunction);
|
|
1721
|
+
if (first.kind === 'describe' && body?.type === 'FunctionExpression')
|
|
1722
|
+
suiteBodies.add(body);
|
|
1480
1723
|
const modifiers = new Map();
|
|
1481
|
-
for (const modifier of new Set(
|
|
1482
|
-
modifiers.set(modifier,
|
|
1724
|
+
for (const modifier of new Set(branches.flatMap((branch) => [...branch.keys()]))) {
|
|
1725
|
+
modifiers.set(modifier, branches.every((branch) => branch.get(modifier) === true));
|
|
1483
1726
|
}
|
|
1484
|
-
const infos =
|
|
1485
|
-
return { subject: makeSubject(
|
|
1727
|
+
const infos = first.kind === 'test' ? bodyInfos(args, first.members) : [];
|
|
1728
|
+
return { subject: makeSubject(first.kind, call, modifiers, tagInfo), infos };
|
|
1486
1729
|
};
|
|
1487
1730
|
/** The details, tags and annotations of a declaration; node:test lets it leave out its title. */
|
|
1488
1731
|
const headOf = (call) => {
|
|
@@ -1516,13 +1759,65 @@ function runAnalysis(sourceCode, options) {
|
|
|
1516
1759
|
suiteBodies.add(body);
|
|
1517
1760
|
return { subject: makeSubject(kind, call, modifiers, tagInfo), infos };
|
|
1518
1761
|
};
|
|
1762
|
+
/** A context or runtime method, following stable local aliases and bound methods. */
|
|
1763
|
+
const resolveInfo = (node, seen = []) => {
|
|
1764
|
+
const value = unwrap(node);
|
|
1765
|
+
if (seen.includes(value))
|
|
1766
|
+
return undefined;
|
|
1767
|
+
const path = [...seen, value];
|
|
1768
|
+
if (value.type === 'CallExpression') {
|
|
1769
|
+
const bind = unwrap(value.callee);
|
|
1770
|
+
if (bind.type !== 'MemberExpression' || memberName(bind) !== 'bind')
|
|
1771
|
+
return undefined;
|
|
1772
|
+
const source = resolveInfo(bind.object, path);
|
|
1773
|
+
return source?.method === undefined
|
|
1774
|
+
? undefined
|
|
1775
|
+
: { ...source, boundArgs: [...(source.boundArgs ?? []), ...value.arguments.slice(1)] };
|
|
1776
|
+
}
|
|
1777
|
+
if (value.type === 'MemberExpression') {
|
|
1778
|
+
const name = memberName(value);
|
|
1779
|
+
if (name === undefined)
|
|
1780
|
+
return undefined;
|
|
1781
|
+
if (value.object.type === 'ThisExpression') {
|
|
1782
|
+
const owner = thisFunction(value);
|
|
1783
|
+
return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
|
|
1784
|
+
? { name, method: name }
|
|
1785
|
+
: undefined;
|
|
1786
|
+
}
|
|
1787
|
+
const object = resolveInfo(value.object, path);
|
|
1788
|
+
return object?.methods?.has(name) ? { name, method: name, step: object.step, owner: object.owner } : undefined;
|
|
1789
|
+
}
|
|
1790
|
+
if (value.type !== 'Identifier' || isReassigned(sourceCode, value))
|
|
1791
|
+
return undefined;
|
|
1792
|
+
const direct = infoOf(value);
|
|
1793
|
+
if (direct !== undefined) {
|
|
1794
|
+
const fn = direct.runtimeFunction;
|
|
1795
|
+
return fn && declaredWhere(sourceCode, value) !== (fn.imported ? 'top' : 'global') ? undefined : direct;
|
|
1796
|
+
}
|
|
1797
|
+
const definition = declaredVariable(sourceCode, value)?.defs.find((def) => def.type === 'Variable');
|
|
1798
|
+
const declarator = definition?.node;
|
|
1799
|
+
if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
|
|
1800
|
+
return undefined;
|
|
1801
|
+
const source = resolveInfo(declarator.init, path);
|
|
1802
|
+
if (source === undefined)
|
|
1803
|
+
return undefined;
|
|
1804
|
+
if (declarator.id.type === 'Identifier')
|
|
1805
|
+
return { ...source, name: value.name, runtimeFunction: undefined };
|
|
1806
|
+
if (declarator.id.type !== 'ObjectPattern' || source.methods === undefined)
|
|
1807
|
+
return undefined;
|
|
1808
|
+
const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === value.name);
|
|
1809
|
+
const name = property?.type === 'Property' ? propertyName(property) : undefined;
|
|
1810
|
+
return name !== undefined && source.methods.has(name)
|
|
1811
|
+
? { name: value.name, method: name, step: source.step, owner: source.owner }
|
|
1812
|
+
: undefined;
|
|
1813
|
+
};
|
|
1519
1814
|
/** node:test's `t.test('title', fn)`: a subtest declared through the context of the test around it. */
|
|
1520
1815
|
const subtestOf = (call) => {
|
|
1521
1816
|
const { callee } = call;
|
|
1522
1817
|
if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
|
|
1523
1818
|
return undefined;
|
|
1524
1819
|
const { object } = callee;
|
|
1525
|
-
if (object.type !== 'Identifier' || !
|
|
1820
|
+
if (object.type !== 'Identifier' || !resolveInfo(object)?.methods)
|
|
1526
1821
|
return undefined;
|
|
1527
1822
|
const declaration = { kind: 'test', members: [], written: [], args: new Map() };
|
|
1528
1823
|
return declares(declaration, call.arguments)
|
|
@@ -1569,11 +1864,16 @@ function runAnalysis(sourceCode, options) {
|
|
|
1569
1864
|
return visited;
|
|
1570
1865
|
};
|
|
1571
1866
|
const visitDeclaredCall = (original, alias) => {
|
|
1572
|
-
const
|
|
1573
|
-
const
|
|
1867
|
+
const normalized = throughFunctionMethods(original);
|
|
1868
|
+
const expanded = alias === undefined
|
|
1869
|
+
? normalized
|
|
1870
|
+
: throughFunctionMethods({ ...normalized, callee: alias });
|
|
1871
|
+
const call = expanded.arguments === normalized.arguments ? normalized : { ...normalized, arguments: expanded.arguments };
|
|
1872
|
+
const written = expanded.callee;
|
|
1574
1873
|
const callee = target(chainOf(written, framework.factories));
|
|
1575
1874
|
if (callee === undefined) {
|
|
1576
|
-
return subtestOf(call) ??
|
|
1875
|
+
return (subtestOf(call) ??
|
|
1876
|
+
pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall({ ...call, callee: written }) });
|
|
1577
1877
|
}
|
|
1578
1878
|
usesTest = true;
|
|
1579
1879
|
const args = call.arguments;
|
|
@@ -1621,88 +1921,21 @@ function runAnalysis(sourceCode, options) {
|
|
|
1621
1921
|
*/
|
|
1622
1922
|
const runtimeCall = (call) => {
|
|
1623
1923
|
const { callee } = call;
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
const resolveInfo = (id) => {
|
|
1629
|
-
const direct = infoOf(id);
|
|
1630
|
-
if (direct !== undefined)
|
|
1631
|
-
return direct;
|
|
1632
|
-
const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
|
|
1633
|
-
const declarator = definition?.node;
|
|
1634
|
-
if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
|
|
1635
|
-
return undefined;
|
|
1636
|
-
const init = unwrap(declarator.init);
|
|
1637
|
-
if (declarator.id.type === 'Identifier') {
|
|
1638
|
-
if (init.type === 'MemberExpression') {
|
|
1639
|
-
const name = memberName(init);
|
|
1640
|
-
if (name === undefined)
|
|
1641
|
-
return undefined;
|
|
1642
|
-
if (init.object.type === 'ThisExpression') {
|
|
1643
|
-
const owner = thisFunction(declarator);
|
|
1644
|
-
return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
|
|
1645
|
-
? { name: id.name, method: name }
|
|
1646
|
-
: undefined;
|
|
1647
|
-
}
|
|
1648
|
-
const object = init.object.type === 'Identifier' ? infoOf(init.object) : undefined;
|
|
1649
|
-
return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
|
|
1650
|
-
}
|
|
1651
|
-
if (init.type !== 'Identifier')
|
|
1652
|
-
return undefined;
|
|
1653
|
-
const source = infoOf(init);
|
|
1654
|
-
if (source === undefined)
|
|
1655
|
-
return undefined;
|
|
1656
|
-
const fn = source.runtimeFunction;
|
|
1657
|
-
if (fn && declaredWhere(sourceCode, init) !== (fn.imported ? 'top' : 'global'))
|
|
1658
|
-
return undefined;
|
|
1659
|
-
if (source.method !== undefined)
|
|
1660
|
-
return { name: id.name, method: source.method, step: source.step };
|
|
1661
|
-
return { name: id.name, methods: source.methods, step: source.step };
|
|
1662
|
-
}
|
|
1663
|
-
if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
|
|
1664
|
-
return undefined;
|
|
1665
|
-
const source = infoOf(init);
|
|
1666
|
-
if (source?.methods === undefined)
|
|
1667
|
-
return undefined;
|
|
1668
|
-
const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
|
|
1669
|
-
if (property?.type !== 'Property')
|
|
1670
|
-
return undefined;
|
|
1671
|
-
const name = propertyName(property);
|
|
1672
|
-
return name !== undefined && source.methods.has(name)
|
|
1673
|
-
? { name: id.name, method: name, step: source.step }
|
|
1674
|
-
: undefined;
|
|
1675
|
-
};
|
|
1676
|
-
if (callee.type === 'Identifier') {
|
|
1677
|
-
const info = resolveInfo(callee);
|
|
1678
|
-
if (info?.method === undefined)
|
|
1679
|
-
return undefined;
|
|
1680
|
-
// A global such as Jasmine's `pending` counts unless the file declares the name itself; an import of
|
|
1681
|
-
// one counts unless a function or block declares the name again.
|
|
1682
|
-
const fn = info.runtimeFunction;
|
|
1683
|
-
if (fn && declaredWhere(sourceCode, callee) !== (fn.imported ? 'top' : 'global'))
|
|
1684
|
-
return undefined;
|
|
1685
|
-
const subject = runtimeSubject(call, info.method, info.step);
|
|
1924
|
+
const info = resolveInfo(callee);
|
|
1925
|
+
if (info?.method !== undefined) {
|
|
1926
|
+
const applied = info.boundArgs === undefined ? call : { ...call, arguments: [...info.boundArgs, ...call.arguments] };
|
|
1927
|
+
const subject = runtimeSubject(applied, info.method, info.step ?? info.owner);
|
|
1686
1928
|
if (subject)
|
|
1687
|
-
subject.functionName =
|
|
1929
|
+
subject.functionName = info.runtimeFunction?.name;
|
|
1688
1930
|
return subject;
|
|
1689
1931
|
}
|
|
1690
|
-
|
|
1932
|
+
const member = unwrap(callee);
|
|
1933
|
+
if (member.type !== 'MemberExpression')
|
|
1691
1934
|
return undefined;
|
|
1692
|
-
const name = memberName(
|
|
1935
|
+
const name = memberName(member);
|
|
1693
1936
|
if (name === undefined)
|
|
1694
1937
|
return undefined;
|
|
1695
|
-
const
|
|
1696
|
-
// Mocha's `this.skip()` or node:test's `this.todo()`, in a test or hook whose `function` gives it the
|
|
1697
|
-
// test's `this`.
|
|
1698
|
-
if (object.type === 'ThisExpression') {
|
|
1699
|
-
const owner = framework.thisMethods.has(name) ? thisFunction(call) : undefined;
|
|
1700
|
-
return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
|
|
1701
|
-
}
|
|
1702
|
-
if (object.type === 'Identifier') {
|
|
1703
|
-
const info = resolveInfo(object);
|
|
1704
|
-
return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
|
|
1705
|
-
}
|
|
1938
|
+
const object = unwrap(member.object);
|
|
1706
1939
|
const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
|
|
1707
1940
|
object.type === 'CallExpression' &&
|
|
1708
1941
|
target(chainOf(object.callee, framework.factories))?.path.join('.') === 'info';
|
|
@@ -1723,20 +1956,37 @@ function runAnalysis(sourceCode, options) {
|
|
|
1723
1956
|
const ownsThis = owner !== undefined && (thisBodies.has(owner) || suiteBodies.has(owner));
|
|
1724
1957
|
return ownsThis ? retrySubject(call, call.arguments.at(0), name) : undefined;
|
|
1725
1958
|
}
|
|
1726
|
-
if (framework.retryCall !== 'jest.retryTimes' || name !== 'retryTimes'
|
|
1959
|
+
if (framework.retryCall !== 'jest.retryTimes' || name !== 'retryTimes') {
|
|
1727
1960
|
return undefined;
|
|
1728
1961
|
}
|
|
1729
|
-
const
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1962
|
+
const isJestObject = (value, seen = []) => {
|
|
1963
|
+
if (seen.includes(value))
|
|
1964
|
+
return false;
|
|
1965
|
+
if (value.type === 'MemberExpression')
|
|
1966
|
+
return memberName(value) === 'jest' && moduleOf(value.object);
|
|
1967
|
+
if (value.type !== 'Identifier' || isReassigned(sourceCode, value))
|
|
1968
|
+
return false;
|
|
1969
|
+
const def = declaredVariable(sourceCode, value)?.defs.at(0);
|
|
1970
|
+
if (def === undefined)
|
|
1971
|
+
return value.name === 'jest';
|
|
1972
|
+
if (def.type === 'ImportBinding')
|
|
1973
|
+
return (def.node.type === 'ImportSpecifier' &&
|
|
1974
|
+
importedName(def.node) === 'jest' &&
|
|
1733
1975
|
def.parent.type === 'ImportDeclaration' &&
|
|
1734
|
-
framework.ownsModule(def.parent.source.value))
|
|
1976
|
+
framework.ownsModule(def.parent.source.value));
|
|
1977
|
+
if (def.type === 'Variable' && def.node.id.type === 'ObjectPattern' && def.node.init !== null) {
|
|
1978
|
+
const property = def.node.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === value.name);
|
|
1979
|
+
return property?.type === 'Property' && propertyName(property) === 'jest' && moduleOf(def.node.init);
|
|
1980
|
+
}
|
|
1981
|
+
const alias = declaredValue(sourceCode, value);
|
|
1982
|
+
return alias !== undefined && isJestObject(unwrap(alias), [...seen, value]);
|
|
1983
|
+
};
|
|
1984
|
+
const isJest = isJestObject(object);
|
|
1735
1985
|
return isJest ? retrySubject(call, call.arguments.at(0), name) : undefined;
|
|
1736
1986
|
};
|
|
1737
1987
|
/** A call that gives retries when `count` does; retries need a ticket even when only some runs get them. */
|
|
1738
1988
|
const retrySubject = (call, count, name) => {
|
|
1739
|
-
if (!retries(count))
|
|
1989
|
+
if (!retries(sourceCode, count))
|
|
1740
1990
|
return undefined;
|
|
1741
1991
|
const subject = runtimeSubject(call, 'retry', undefined, true);
|
|
1742
1992
|
if (subject)
|
|
@@ -1744,7 +1994,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1744
1994
|
return subject;
|
|
1745
1995
|
};
|
|
1746
1996
|
/** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
|
|
1747
|
-
const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
|
|
1997
|
+
const runtimeSubject = (call, modifier, step, always = isUnconditional(sourceCode, call, framework)) => {
|
|
1748
1998
|
if (!states.some((s) => s.modifier === modifier))
|
|
1749
1999
|
return undefined;
|
|
1750
2000
|
if (framework.reasonArgument !== 'only' && booleanOf(call.arguments.at(0)) === false)
|
|
@@ -1768,6 +2018,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
1768
2018
|
pushedSubject = true;
|
|
1769
2019
|
}
|
|
1770
2020
|
for (const info of visit.infos ?? []) {
|
|
2021
|
+
if (!info.runtimeFunction && info.step === undefined && visit.subject?.kind === 'test')
|
|
2022
|
+
info.owner = visit.subject;
|
|
1771
2023
|
infos.push(info);
|
|
1772
2024
|
pushedInfos++;
|
|
1773
2025
|
}
|
|
@@ -1937,7 +2189,10 @@ export function headLoc(subject) {
|
|
|
1937
2189
|
// todo test has only a title.
|
|
1938
2190
|
const [first] = node.arguments;
|
|
1939
2191
|
const title = isFunction(first) || first.type === 'ObjectExpression' ? undefined : first;
|
|
1940
|
-
return {
|
|
2192
|
+
return {
|
|
2193
|
+
start: node.callee.loc.start,
|
|
2194
|
+
end: (title && title.range[1] > node.callee.range[1] ? title : node.callee).loc.end,
|
|
2195
|
+
};
|
|
1941
2196
|
}
|
|
1942
2197
|
export function describeSubject(subject) {
|
|
1943
2198
|
if (subject.kind === 'describe')
|
package/dist/utils/fix.js
CHANGED
|
@@ -28,9 +28,10 @@ export function removeMarkerComment(sourceCode, marker) {
|
|
|
28
28
|
return { range: [valueStart + description.separator, valueStart + comment.value.trimEnd().length], text: '' };
|
|
29
29
|
}
|
|
30
30
|
const { text } = sourceCode;
|
|
31
|
-
const lineStart =
|
|
32
|
-
const
|
|
33
|
-
|
|
31
|
+
const lineStart = sourceCode.getIndexFromLoc({ line: comment.loc.start.line, column: 0 });
|
|
32
|
+
const lineEnd = comment.loc.end.line < sourceCode.lines.length
|
|
33
|
+
? sourceCode.getIndexFromLoc({ line: comment.loc.end.line + 1, column: 0 })
|
|
34
|
+
: text.length;
|
|
34
35
|
const aloneOnLine = text.slice(lineStart, comment.range[0]).trim() === '' && text.slice(comment.range[1], lineEnd).trim() === '';
|
|
35
36
|
return { range: aloneOnLine ? [lineStart, lineEnd] : comment.range, text: '' };
|
|
36
37
|
}
|
|
@@ -47,7 +48,7 @@ export function indentOf(sourceCode, comment) {
|
|
|
47
48
|
}
|
|
48
49
|
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/;
|
|
49
50
|
function quote(text) {
|
|
50
|
-
return `'${text.replace(/[\\']/g, '\\$&')}'`;
|
|
51
|
+
return `'${text.replace(/[\\']/g, '\\$&').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`;
|
|
51
52
|
}
|
|
52
53
|
/**
|
|
53
54
|
* Moves a marker's tickets into an annotation on `call`: Playwright's `annotation` or Vitest's `meta`,
|
|
@@ -64,7 +65,7 @@ export function moveToAnnotation(sourceCode, call, marker, source, type) {
|
|
|
64
65
|
return undefined;
|
|
65
66
|
const one = (ticket) => `{ type: ${quote(type)}, description: ${quote(ticket)} }`;
|
|
66
67
|
const value = source.shape === 'meta'
|
|
67
|
-
? `{ ${IDENTIFIER_RE.test(type) ? type : quote(type)}: ${quote(tickets[0])} }`
|
|
68
|
+
? `{ ${type === '__proto__' ? `[${quote(type)}]` : IDENTIFIER_RE.test(type) ? type : quote(type)}: ${quote(tickets[0])} }`
|
|
68
69
|
: tickets.length === 1
|
|
69
70
|
? one(tickets[0])
|
|
70
71
|
: `[${tickets.map(one).join(', ')}]`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-test-governance",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "ESLint rules that require a ticket for skipped, fixme, todo, retried, tagged and commented-out Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|