falsegreen-js 0.3.0 → 0.5.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.
- package/CHANGELOG.md +140 -1
- package/README.md +72 -13
- package/dist/cases.d.ts +51 -3
- package/dist/cases.js +91 -43
- package/dist/cfg.d.ts +32 -0
- package/dist/cfg.js +237 -0
- package/dist/cli.d.ts +27 -1
- package/dist/cli.js +136 -41
- package/dist/oracles.d.ts +52 -0
- package/dist/oracles.js +87 -0
- package/dist/report.d.ts +31 -0
- package/dist/report.js +177 -0
- package/dist/rules.js +538 -90
- package/dist/scan.d.ts +1 -0
- package/dist/scan.js +0 -0
- package/dist/types.js +2 -2
- package/package.json +34 -9
package/dist/rules.js
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { makeFinding } from "./types.js";
|
|
3
3
|
import { DIAGNOSTIC_THRESHOLDS } from "./cases.js";
|
|
4
|
+
import { ASSERT_ROOTS, ASSERT_METHODS, SNAPSHOT_MATCHERS, EQUALITY_MATCHERS, VUE_SVELTE_ASYNC, oracleKind, } from "./oracles.js";
|
|
4
5
|
import { lineOf } from "./parse.js";
|
|
6
|
+
import { assertionsInDeadCode, hasUnconditionalAssertion } from "./cfg.js";
|
|
5
7
|
// --- test framework vocabulary (runner-agnostic) ---------------------------
|
|
6
8
|
// it/test/specify (Jest, Vitest, Mocha, Jasmine, AVA, node:test, Cypress,
|
|
7
9
|
// Playwright, tap). describe/context/suite are suites. fit/fdescribe focus and
|
|
8
|
-
// xit/xdescribe skip come from Jasmine/Mocha.
|
|
10
|
+
// xit/xdescribe skip come from Jasmine/Mocha. The assertion-API vocabulary
|
|
11
|
+
// (ASSERT_ROOTS/ASSERT_METHODS/SNAPSHOT_MATCHERS/EQUALITY_MATCHERS and the async
|
|
12
|
+
// leaves) lives in the oracle registry (oracles.ts), imported above. JS5 routes
|
|
13
|
+
// its async detection through oracleKind() instead of a hand-rolled name list.
|
|
9
14
|
const TEST_BLOCK_ROOTS = new Set(["it", "test", "specify"]);
|
|
10
15
|
const SUITE_ROOTS = new Set(["describe", "context", "suite", "fdescribe", "xdescribe", "fcontext", "xcontext"]);
|
|
11
16
|
const FOCUS_NAMES = new Set(["fit", "fdescribe", "fcontext"]);
|
|
12
17
|
const SKIP_NAMES = new Set(["xit", "xdescribe", "xcontext", "xspecify"]);
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
const
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"same", "notSame", "throws", "notThrows", "throwsAsync", "regex", "notRegex",
|
|
21
|
-
"pass", "fail", "assert", "expect", "include", "match",
|
|
18
|
+
// --- C48 dark-patch: a test that flips a known test-mode flag then asserts ---
|
|
19
|
+
// env keys (process.env.<KEY> = <test-mode value>) whose name means "we are under
|
|
20
|
+
// test". NODE_ENV only counts when set to "test" (production/development are real
|
|
21
|
+
// configs); CI is excluded (infra, not a product branch).
|
|
22
|
+
const ENV_TEST_MODE_KEYS = new Set([
|
|
23
|
+
"NODE_ENV", "JEST_WORKER_ID", "VITEST", "TEST", "TESTING", "TEST_MODE",
|
|
24
|
+
"TESTMODE", "UNDER_TEST", "IS_TEST",
|
|
22
25
|
]);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// visual snapshots (Playwright): the baseline is generated from the output too
|
|
27
|
-
"toHaveScreenshot", "toMatchScreenshot",
|
|
28
|
-
]);
|
|
29
|
-
const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
|
|
26
|
+
// module/settings flag names (settings.TESTING = true, config.TEST_MODE = true).
|
|
27
|
+
const MODULE_TEST_MODE_RE = /^(TESTING|TEST_MODE|IS_TEST|UNDER_TEST|_TESTING)$/;
|
|
28
|
+
const TEST_MODE_TRUE_STRINGS = new Set(["1", "true", "test", "yes", "on"]);
|
|
30
29
|
// --- name helpers ----------------------------------------------------------
|
|
31
30
|
function calleeName(expr) {
|
|
32
31
|
if (ts.isIdentifier(expr))
|
|
@@ -79,6 +78,58 @@ function expectRooted(e) {
|
|
|
79
78
|
}
|
|
80
79
|
return false;
|
|
81
80
|
}
|
|
81
|
+
// --- JS24: Cypress query chain with no terminating assertion ---------------
|
|
82
|
+
const CY_QUERY_COMMANDS = new Set(["get", "find", "contains"]);
|
|
83
|
+
/** True if any `expect(...)` call appears under `scope` (any matcher form, or a
|
|
84
|
+
* bare expect). Used to keep a cy chain clean when it asserts inside a .then. */
|
|
85
|
+
function containsExpectCall(scope) {
|
|
86
|
+
let found = false;
|
|
87
|
+
const walk = (n) => {
|
|
88
|
+
if (found)
|
|
89
|
+
return;
|
|
90
|
+
if (ts.isCallExpression(n) && expectRooted(n)) {
|
|
91
|
+
found = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
ts.forEachChild(n, walk);
|
|
95
|
+
};
|
|
96
|
+
walk(scope);
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
/** True if a call chain rooted at `cy` ends in a query command (get/find/contains)
|
|
100
|
+
* and carries no terminating `.should`/`.and` and no `expect(...)` inside a `.then`
|
|
101
|
+
* callback — so it produces a subject that is never asserted. Action commands
|
|
102
|
+
* (click/type/visit/...) as the outermost call do something, so they stay clean.
|
|
103
|
+
* `expr` is the outermost call of the statement. */
|
|
104
|
+
function isUnassertedCyQuery(expr) {
|
|
105
|
+
if (rootIdent(expr) !== "cy")
|
|
106
|
+
return false;
|
|
107
|
+
// outermost command must be a query, not an action (action does work, not just query)
|
|
108
|
+
if (!ts.isPropertyAccessExpression(expr.expression))
|
|
109
|
+
return false;
|
|
110
|
+
if (!CY_QUERY_COMMANDS.has(expr.expression.name.text))
|
|
111
|
+
return false;
|
|
112
|
+
// scan the whole chain for a terminating assertion: any .should/.and, or an
|
|
113
|
+
// expect(...) inside a .then(cb) callback. Either keeps it clean.
|
|
114
|
+
let cur = expr;
|
|
115
|
+
const visit = (n) => {
|
|
116
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression)) {
|
|
117
|
+
const m = n.expression.name.text;
|
|
118
|
+
if (m === "should" || m === "and")
|
|
119
|
+
return true;
|
|
120
|
+
if (m === "then") {
|
|
121
|
+
const cb = n.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
|
122
|
+
if (cb && containsExpectCall(cb))
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let asserted = false;
|
|
127
|
+
ts.forEachChild(n, (c) => { if (visit(c))
|
|
128
|
+
asserted = true; });
|
|
129
|
+
return asserted;
|
|
130
|
+
};
|
|
131
|
+
return !visit(cur);
|
|
132
|
+
}
|
|
82
133
|
function literalTruthiness(e) {
|
|
83
134
|
if (!e)
|
|
84
135
|
return null;
|
|
@@ -119,35 +170,6 @@ function isStringify(e) {
|
|
|
119
170
|
}
|
|
120
171
|
return false;
|
|
121
172
|
}
|
|
122
|
-
const CONDITIONAL_ANCESTORS = new Set([
|
|
123
|
-
ts.SyntaxKind.IfStatement, ts.SyntaxKind.ForStatement, ts.SyntaxKind.ForOfStatement,
|
|
124
|
-
ts.SyntaxKind.ForInStatement, ts.SyntaxKind.WhileStatement, ts.SyntaxKind.DoStatement,
|
|
125
|
-
ts.SyntaxKind.SwitchStatement, ts.SyntaxKind.CatchClause, ts.SyntaxKind.ConditionalExpression,
|
|
126
|
-
]);
|
|
127
|
-
/** True if the function has at least one assertion and every one of them sits under a
|
|
128
|
-
* conditional (if/for/while/switch/catch/?:) — so none runs unconditionally (C21). */
|
|
129
|
-
function assertionsAllConditional(fn) {
|
|
130
|
-
const asserts = [];
|
|
131
|
-
const walk = (n) => { if (isAssertionNode(n))
|
|
132
|
-
asserts.push(n); ts.forEachChild(n, walk); };
|
|
133
|
-
ts.forEachChild(fn, walk);
|
|
134
|
-
if (asserts.length === 0)
|
|
135
|
-
return false;
|
|
136
|
-
for (const a of asserts) {
|
|
137
|
-
let p = a.parent;
|
|
138
|
-
let conditional = false;
|
|
139
|
-
while (p && p !== fn) {
|
|
140
|
-
if (CONDITIONAL_ANCESTORS.has(p.kind)) {
|
|
141
|
-
conditional = true;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
p = p.parent;
|
|
145
|
-
}
|
|
146
|
-
if (!conditional)
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
173
|
function containsCall(node) {
|
|
152
174
|
let found = false;
|
|
153
175
|
const walk = (n) => {
|
|
@@ -181,18 +203,33 @@ function expectChain(call) {
|
|
|
181
203
|
}
|
|
182
204
|
return null;
|
|
183
205
|
}
|
|
184
|
-
/** True if a call's result is observed: awaited, returned, assigned,
|
|
185
|
-
* implicit-return body of an arrow
|
|
186
|
-
*
|
|
187
|
-
*
|
|
206
|
+
/** True if a call's result is observed: awaited, returned, assigned, the
|
|
207
|
+
* implicit-return body of an arrow, or explicitly discarded with `void` (an
|
|
208
|
+
* author signalling "I am dropping this on purpose"). A bare floating call (its
|
|
209
|
+
* enclosing statement is a plain ExpressionStatement) is NOT observed. Used to
|
|
210
|
+
* gate supertest `.expect()` so a floating API request still surfaces as C2b,
|
|
211
|
+
* and JS5 so a dropped async query/event still surfaces. */
|
|
188
212
|
function isObservedAsync(node) {
|
|
189
213
|
let cur = node;
|
|
190
214
|
let p = node.parent;
|
|
191
215
|
while (p) {
|
|
192
216
|
if (ts.isAwaitExpression(p) || ts.isReturnStatement(p))
|
|
193
217
|
return true;
|
|
194
|
-
if (ts.isVariableDeclaration(p)
|
|
218
|
+
if (ts.isVariableDeclaration(p))
|
|
195
219
|
return true;
|
|
220
|
+
// A BinaryExpression only observes the call when it is a real assignment
|
|
221
|
+
// (`=` or a compound `+=` etc., kinds in FirstAssignment..LastAssignment) and
|
|
222
|
+
// the call is the right-hand side. A logical/comparison/arithmetic operator
|
|
223
|
+
// (`||`, `&&`, `===`, `+`, ...) does NOT observe it: `findBy*() || expect(...)`
|
|
224
|
+
// still floats the promise and must surface as JS5.
|
|
225
|
+
if (ts.isBinaryExpression(p)) {
|
|
226
|
+
const k = p.operatorToken.kind;
|
|
227
|
+
const isAssign = k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment;
|
|
228
|
+
if (isAssign && p.right === cur)
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (ts.isVoidExpression(p))
|
|
232
|
+
return true; // `void expr` — discarded on purpose
|
|
196
233
|
if (ts.isArrowFunction(p) && p.body === cur)
|
|
197
234
|
return true;
|
|
198
235
|
if (ts.isExpressionStatement(p))
|
|
@@ -260,6 +297,54 @@ function hasAssertion(scope) {
|
|
|
260
297
|
function containsAssertion(node) {
|
|
261
298
|
return isAssertionNode(node) || hasAssertion(node);
|
|
262
299
|
}
|
|
300
|
+
/** Visit descendants of `node` without entering nested function scopes (a helper
|
|
301
|
+
* def/arrow/method in the test body is its own scope, not the test's). */
|
|
302
|
+
function forEachNoNesting(node, visit) {
|
|
303
|
+
ts.forEachChild(node, (child) => {
|
|
304
|
+
visit(child);
|
|
305
|
+
if (!ts.isFunctionLike(child))
|
|
306
|
+
forEachNoNesting(child, visit);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** The env key of a `process.env.KEY = ...` / `process.env["KEY"] = ...` target, else null. */
|
|
310
|
+
function envAssignKey(lhs) {
|
|
311
|
+
const isProcessEnv = (e) => ts.isPropertyAccessExpression(e) && e.name.text === "env" &&
|
|
312
|
+
ts.isIdentifier(e.expression) && e.expression.text === "process";
|
|
313
|
+
if (ts.isPropertyAccessExpression(lhs) && isProcessEnv(lhs.expression))
|
|
314
|
+
return lhs.name.text;
|
|
315
|
+
if (ts.isElementAccessExpression(lhs) && isProcessEnv(lhs.expression) &&
|
|
316
|
+
ts.isStringLiteralLike(lhs.argumentExpression))
|
|
317
|
+
return lhs.argumentExpression.text;
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
/** A value that puts a test-mode flag into test mode. NODE_ENV only counts as "test";
|
|
321
|
+
* every other key takes true/1/"1"/"true"/"test"/"yes"/"on". */
|
|
322
|
+
function isTestModeValue(rhs, key) {
|
|
323
|
+
if (key === "NODE_ENV")
|
|
324
|
+
return ts.isStringLiteralLike(rhs) && rhs.text === "test";
|
|
325
|
+
if (rhs.kind === ts.SyntaxKind.TrueKeyword)
|
|
326
|
+
return true;
|
|
327
|
+
if (ts.isNumericLiteral(rhs))
|
|
328
|
+
return rhs.text === "1";
|
|
329
|
+
if (ts.isStringLiteralLike(rhs))
|
|
330
|
+
return TEST_MODE_TRUE_STRINGS.has(rhs.text.trim().toLowerCase());
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
/** True if `bin` is a raw write that flips a known test-mode toggle into test mode:
|
|
334
|
+
* process.env.<KEY> = <test value>, or <obj>.TESTING = <truthy> (obj not `this`). */
|
|
335
|
+
function isTestModeToggleWrite(bin) {
|
|
336
|
+
if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
|
|
337
|
+
return false;
|
|
338
|
+
const lhs = bin.left;
|
|
339
|
+
const envKey = envAssignKey(lhs);
|
|
340
|
+
if (envKey && ENV_TEST_MODE_KEYS.has(envKey) && isTestModeValue(bin.right, envKey))
|
|
341
|
+
return true;
|
|
342
|
+
if (ts.isPropertyAccessExpression(lhs) && MODULE_TEST_MODE_RE.test(lhs.name.text) &&
|
|
343
|
+
lhs.expression.kind !== ts.SyntaxKind.ThisKeyword &&
|
|
344
|
+
isTestModeValue(bin.right, lhs.name.text))
|
|
345
|
+
return true;
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
263
348
|
/** True if a catch block does nothing meaningful: empty, or only console.* /
|
|
264
349
|
* comments, with no throw, no assertion, and no fail() — so it swallows errors. */
|
|
265
350
|
function isHarmlessCatch(block) {
|
|
@@ -295,6 +380,139 @@ function matchersUnder(scope) {
|
|
|
295
380
|
ts.forEachChild(scope, walk);
|
|
296
381
|
return out;
|
|
297
382
|
}
|
|
383
|
+
/** True if any snapshot matcher under `scope` is an inline snapshot with no
|
|
384
|
+
* baseline yet: `toMatchInlineSnapshot()` / `toThrowErrorMatchingInlineSnapshot()`
|
|
385
|
+
* with no argument, or an empty/whitespace-only string-literal baseline. On the
|
|
386
|
+
* first run the runner writes the snapshot from the output itself, so it passes
|
|
387
|
+
* by construction. A populated inline snapshot has a real baseline and is not this. */
|
|
388
|
+
function hasEmptyInlineSnapshot(scope) {
|
|
389
|
+
let found = false;
|
|
390
|
+
const walk = (n) => {
|
|
391
|
+
if (found)
|
|
392
|
+
return;
|
|
393
|
+
if (ts.isCallExpression(n)) {
|
|
394
|
+
const chain = expectChain(n);
|
|
395
|
+
if (chain && (chain.matcher === "toMatchInlineSnapshot" ||
|
|
396
|
+
chain.matcher === "toThrowErrorMatchingInlineSnapshot")) {
|
|
397
|
+
const a0 = chain.args[0];
|
|
398
|
+
if (a0 === undefined) {
|
|
399
|
+
found = true;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if ((ts.isStringLiteral(a0) || ts.isNoSubstitutionTemplateLiteral(a0)) &&
|
|
403
|
+
a0.text.trim() === "") {
|
|
404
|
+
found = true;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
ts.forEachChild(n, walk);
|
|
410
|
+
};
|
|
411
|
+
ts.forEachChild(scope, walk);
|
|
412
|
+
return found;
|
|
413
|
+
}
|
|
414
|
+
/** The numeric N of an `expect.assertions(N)` call (N a numeric literal), else
|
|
415
|
+
* null. `expect.hasAssertions()` carries no count and is not this. */
|
|
416
|
+
function expectAssertionsCount(call) {
|
|
417
|
+
if (calleeName(call.expression) !== "expect.assertions")
|
|
418
|
+
return null;
|
|
419
|
+
const a0 = call.arguments[0];
|
|
420
|
+
if (a0 && ts.isNumericLiteral(a0))
|
|
421
|
+
return Number(a0.text);
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
/** JS23 expect accounting. `unconditional` is the count of expect-chain matcher
|
|
425
|
+
* calls guaranteed to run: a direct ExpressionStatement on the test body's spine
|
|
426
|
+
* (optionally awaited). `indeterminate` is true when any other expect-chain call
|
|
427
|
+
* exists in the body (in a loop, branch, try, switch, callback, .then, or an
|
|
428
|
+
* expression operand) — its run count cannot be proven, so a shortfall is not
|
|
429
|
+
* provable and JS23 must be suppressed (FP-averse: a false positive is worse than
|
|
430
|
+
* a miss). Stops at nested functions only for the spine count, but the
|
|
431
|
+
* indeterminate scan walks the whole body (a .then callback IS indeterminate). */
|
|
432
|
+
function expectAccounting(body) {
|
|
433
|
+
const spine = new Set();
|
|
434
|
+
let unconditional = 0;
|
|
435
|
+
for (const st of body.statements) {
|
|
436
|
+
if (!ts.isExpressionStatement(st))
|
|
437
|
+
continue;
|
|
438
|
+
let e = st.expression;
|
|
439
|
+
if (ts.isAwaitExpression(e))
|
|
440
|
+
e = e.expression;
|
|
441
|
+
if (ts.isCallExpression(e) && expectChain(e)) {
|
|
442
|
+
unconditional++;
|
|
443
|
+
spine.add(e);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Anything that is not the proven-unconditional expect spine makes the count
|
|
447
|
+
// indeterminate: an off-spine expect chain (loop/branch/.then/operand), or any
|
|
448
|
+
// other call — a helper or setup call may carry assertions the count cannot see.
|
|
449
|
+
// Do not descend into a spine chain (its inner expect()/matcher calls are
|
|
450
|
+
// accounted, not separate helpers).
|
|
451
|
+
let indeterminate = false;
|
|
452
|
+
const walk = (n) => {
|
|
453
|
+
if (indeterminate)
|
|
454
|
+
return;
|
|
455
|
+
if (spine.has(n))
|
|
456
|
+
return; // whole spine chain already counted
|
|
457
|
+
if (ts.isCallExpression(n)) {
|
|
458
|
+
const nm = calleeName(n.expression);
|
|
459
|
+
if (nm !== "expect.assertions" && nm !== "expect.hasAssertions") {
|
|
460
|
+
indeterminate = true;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
ts.forEachChild(n, walk);
|
|
465
|
+
};
|
|
466
|
+
walk(body);
|
|
467
|
+
return { unconditional, indeterminate };
|
|
468
|
+
}
|
|
469
|
+
/** JS8 (spyOn form): collect `{root -> line}` for every jest.spyOn/vi.spyOn target
|
|
470
|
+
* whose return was canned (mockReturnValue/mockResolvedValue/mockRejectedValue/
|
|
471
|
+
* mockImplementation) under `scope`. Test-local on purpose: a spyOn is hoisted only
|
|
472
|
+
* within its own test body, unlike the module-wide jest.mock form. */
|
|
473
|
+
function cannedSpyTargets(scope) {
|
|
474
|
+
const out = new Map();
|
|
475
|
+
const sf = scope.getSourceFile();
|
|
476
|
+
const walk = (n) => {
|
|
477
|
+
if (ts.isCallExpression(n) &&
|
|
478
|
+
/^(mockReturnValue|mockResolvedValue|mockRejectedValue|mockImplementation)$/.test(calleeName(n.expression).split(".").pop() ?? "")) {
|
|
479
|
+
let base = n.expression;
|
|
480
|
+
while (ts.isPropertyAccessExpression(base) || ts.isCallExpression(base)) {
|
|
481
|
+
if (ts.isCallExpression(base)) {
|
|
482
|
+
const cn = calleeName(base.expression);
|
|
483
|
+
if (cn === "jest.spyOn" || cn === "vi.spyOn") {
|
|
484
|
+
const tr = base.arguments[0] && rootIdent(base.arguments[0]);
|
|
485
|
+
if (tr)
|
|
486
|
+
out.set(tr, lineOf(sf, n));
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
base = ts.isCallExpression(base) ? base.expression
|
|
491
|
+
: base.expression;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
ts.forEachChild(n, walk);
|
|
495
|
+
};
|
|
496
|
+
walk(scope);
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
/** Root identifiers used as an expect subject under `scope` (`expect(a.b).m()` -> "a"). */
|
|
500
|
+
function expectSubjectRoots(scope) {
|
|
501
|
+
const out = new Set();
|
|
502
|
+
const walk = (n) => {
|
|
503
|
+
if (ts.isCallExpression(n)) {
|
|
504
|
+
const chain = expectChain(n);
|
|
505
|
+
if (chain && chain.subject) {
|
|
506
|
+
const r = rootIdent(chain.subject);
|
|
507
|
+
if (r)
|
|
508
|
+
out.add(r);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
ts.forEachChild(n, walk);
|
|
512
|
+
};
|
|
513
|
+
walk(scope);
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
298
516
|
function getTestCallback(call) {
|
|
299
517
|
for (const arg of call.arguments) {
|
|
300
518
|
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg))
|
|
@@ -302,27 +520,137 @@ function getTestCallback(call) {
|
|
|
302
520
|
}
|
|
303
521
|
return null;
|
|
304
522
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
function
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
523
|
+
/** The function body that encloses `node` for timer-flush scoping: the nearest
|
|
524
|
+
* ancestor arrow/function that is the callback of an it/test/specify call (so a
|
|
525
|
+
* flush in one test never reaches a timer in another), falling back to the
|
|
526
|
+
* nearest enclosing function, then the whole source file. */
|
|
527
|
+
function enclosingTestScope(node) {
|
|
528
|
+
let fallback = null;
|
|
529
|
+
let p = node.parent;
|
|
530
|
+
while (p) {
|
|
531
|
+
if (ts.isArrowFunction(p) || ts.isFunctionExpression(p) || ts.isFunctionDeclaration(p)) {
|
|
532
|
+
if (!fallback)
|
|
533
|
+
fallback = p;
|
|
534
|
+
const call = p.parent;
|
|
535
|
+
if (call && ts.isCallExpression(call) && call.arguments.includes(p)) {
|
|
536
|
+
const root = calleeName(call.expression).split(".")[0];
|
|
537
|
+
if (TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit")
|
|
538
|
+
return p;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
p = p.parent;
|
|
542
|
+
}
|
|
543
|
+
return fallback ?? node.getSourceFile();
|
|
544
|
+
}
|
|
545
|
+
const FAKE_TIMER_INSTALL = /\b(useFakeTimers|installFakeTimers)\b/;
|
|
546
|
+
const FAKE_TIMER_FLUSH = /\b(runAllTimers|runOnlyPendingTimers|advanceTimersByTime|tick)\b/;
|
|
547
|
+
const SETUP_HOOKS = new Set(["beforeEach", "beforeAll"]);
|
|
548
|
+
const TEARDOWN_HOOKS = new Set(["afterEach", "afterAll"]);
|
|
549
|
+
/** True if any call under `scope` matches `re`. */
|
|
550
|
+
function callMatchesUnder(scope, sf, re) {
|
|
551
|
+
let found = false;
|
|
552
|
+
const walk = (n) => {
|
|
553
|
+
if (found)
|
|
554
|
+
return;
|
|
555
|
+
if (ts.isCallExpression(n) && re.test(calleeName(n.expression))) {
|
|
556
|
+
found = true;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
ts.forEachChild(n, walk);
|
|
560
|
+
};
|
|
561
|
+
walk(scope);
|
|
562
|
+
return found;
|
|
563
|
+
}
|
|
564
|
+
/** Scan one statement list for a lifecycle hook that drives the timer: a
|
|
565
|
+
* fake-timer install in a beforeEach/beforeAll runs before every test in scope,
|
|
566
|
+
* a flush/advance in an afterEach/afterAll runs after. Order inside the hook does
|
|
567
|
+
* not matter — the runner sequences the hook around the test body. */
|
|
568
|
+
function hookStatementsControlTimer(statements, sf) {
|
|
569
|
+
for (const stmt of statements) {
|
|
570
|
+
if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression))
|
|
571
|
+
continue;
|
|
572
|
+
const hook = calleeName(stmt.expression.expression).split(".")[0];
|
|
573
|
+
const cb = getTestCallback(stmt.expression);
|
|
574
|
+
if (!cb)
|
|
575
|
+
continue;
|
|
576
|
+
if (SETUP_HOOKS.has(hook) && callMatchesUnder(cb, sf, FAKE_TIMER_INSTALL))
|
|
577
|
+
return true;
|
|
578
|
+
if (TEARDOWN_HOOKS.has(hook) && callMatchesUnder(cb, sf, FAKE_TIMER_FLUSH))
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
/** A timer can be driven from a sibling lifecycle hook rather than the test body.
|
|
584
|
+
* Top-level hooks (outside any describe) wrap every test in the file, and hooks
|
|
585
|
+
* in any enclosing describe/suite wrap every test nested under it. Check both. */
|
|
586
|
+
function hookControlsTimer(timer, sf) {
|
|
587
|
+
// Top-level hooks live directly in the source file and apply to every test.
|
|
588
|
+
if (hookStatementsControlTimer(sf.statements, sf))
|
|
314
589
|
return true;
|
|
315
|
-
|
|
590
|
+
let p = timer.parent;
|
|
591
|
+
while (p) {
|
|
592
|
+
// The body of an enclosing describe/suite callback: scan its top-level hook calls.
|
|
593
|
+
if ((ts.isArrowFunction(p) || ts.isFunctionExpression(p) || ts.isFunctionDeclaration(p)) &&
|
|
594
|
+
p.parent && ts.isCallExpression(p.parent) &&
|
|
595
|
+
p.parent.arguments.includes(p)) {
|
|
596
|
+
const suiteRoot = calleeName(p.parent.expression).split(".")[0];
|
|
597
|
+
if (SUITE_ROOTS.has(suiteRoot) && p.body && ts.isBlock(p.body)) {
|
|
598
|
+
if (hookStatementsControlTimer(p.body.statements, sf))
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
p = p.parent;
|
|
603
|
+
}
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
/** Precise replacement for the old file-wide fake-timer suppression. A
|
|
607
|
+
* setTimeout/setInterval is "controlled" when, inside the same enclosing test
|
|
608
|
+
* callback, either a fake-timer install runs BEFORE it or a flush/advance call
|
|
609
|
+
* runs AFTER it; OR when an enclosing describe drives the timer through a sibling
|
|
610
|
+
* hook (install in beforeEach/beforeAll, flush in afterEach/afterAll). A flush
|
|
611
|
+
* before the arm (callback never ran) or a flush in a different test does not
|
|
612
|
+
* count. */
|
|
613
|
+
function timerIsControlled(timer, sf) {
|
|
614
|
+
const scope = enclosingTestScope(timer);
|
|
615
|
+
const armPos = timer.getStart(sf);
|
|
616
|
+
let controlled = false;
|
|
617
|
+
const walk = (n) => {
|
|
618
|
+
if (controlled)
|
|
619
|
+
return;
|
|
620
|
+
if (ts.isCallExpression(n)) {
|
|
621
|
+
const name = calleeName(n.expression);
|
|
622
|
+
const pos = n.getStart(sf);
|
|
623
|
+
if (FAKE_TIMER_INSTALL.test(name) && pos < armPos) {
|
|
624
|
+
controlled = true;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (FAKE_TIMER_FLUSH.test(name) && pos > armPos) {
|
|
628
|
+
controlled = true;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
ts.forEachChild(n, walk);
|
|
633
|
+
};
|
|
634
|
+
walk(scope);
|
|
635
|
+
if (controlled)
|
|
316
636
|
return true;
|
|
317
|
-
return
|
|
637
|
+
return hookControlsTimer(timer, sf);
|
|
318
638
|
}
|
|
319
639
|
// --- C16: nondeterminism ----------------------------------------------------
|
|
320
640
|
function c16Detail(call) {
|
|
321
641
|
const name = calleeName(call.expression);
|
|
642
|
+
const leaf = name.split(".").pop() ?? "";
|
|
322
643
|
if (name === "Math.random")
|
|
323
644
|
return "Math.random() without a fixed seed";
|
|
324
645
|
if (name === "Date.now" || name === "performance.now")
|
|
325
646
|
return "reads the system clock";
|
|
647
|
+
// crypto.randomUUID() / crypto.getRandomValues() (incl. globalThis/window/self.crypto and
|
|
648
|
+
// the bare node:crypto import) produce a fresh random value each run with no seed. Anchored
|
|
649
|
+
// to a crypto root so a user method named randomUUID()/getRandomValues() is NOT flagged.
|
|
650
|
+
const isCryptoRandom = (m) => name === "crypto." + m || name.endsWith(".crypto." + m) || name === m;
|
|
651
|
+
if (isCryptoRandom("randomUUID") || isCryptoRandom("getRandomValues")) {
|
|
652
|
+
return "crypto randomness without a seed";
|
|
653
|
+
}
|
|
326
654
|
if (name === "setTimeout" || name === "setInterval") {
|
|
327
655
|
if (call.arguments.length >= 2 && ts.isNumericLiteral(call.arguments[1])) {
|
|
328
656
|
return "fixed timer delay";
|
|
@@ -337,7 +665,12 @@ export function analyze(sf) {
|
|
|
337
665
|
const findings = [];
|
|
338
666
|
const file = sf.fileName;
|
|
339
667
|
const text = sf.getFullText();
|
|
340
|
-
|
|
668
|
+
// Fake-timer / flush presence suppresses the JS7 timer arm: if the test fakes
|
|
669
|
+
// or drives timers anywhere, a setTimeout/setInterval callback is flushed
|
|
670
|
+
// synchronously and its assertion does run. Covers Jest, Vitest and Sinon
|
|
671
|
+
// install calls plus the explicit advance/run calls (jest/vi runAllTimers,
|
|
672
|
+
// runOnlyPendingTimers, advanceTimersByTime, sinon clock.tick).
|
|
673
|
+
const fakeTimers = /\b(useFakeTimers|installFakeTimers|runAllTimers|runOnlyPendingTimers|advanceTimersByTime|tick)\b/.test(text);
|
|
341
674
|
const push = (line, code, detail = "") => {
|
|
342
675
|
findings.push(makeFinding(file, line, code, detail));
|
|
343
676
|
};
|
|
@@ -380,8 +713,7 @@ export function analyze(sf) {
|
|
|
380
713
|
// test-level block body checks (C2, C2b, JS3). Only `it`/`test`/`specify`
|
|
381
714
|
// (and the focus/skip variants) — never `describe`/`suite`, whose body holds
|
|
382
715
|
// nested tests, not assertions.
|
|
383
|
-
const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit"
|
|
384
|
-
((TEST_BLOCK_ROOTS.has(root)) && (modifier === "only" || modifier === "skip" || modifier === "each"));
|
|
716
|
+
const isTestBlock = TEST_BLOCK_ROOTS.has(root) || root === "fit" || root === "xit";
|
|
385
717
|
if (isTestBlock) {
|
|
386
718
|
const cb = getTestCallback(node);
|
|
387
719
|
// JS18: the test takes a `done` callback instead of async/await. A done
|
|
@@ -396,14 +728,31 @@ export function analyze(sf) {
|
|
|
396
728
|
if (cb && cb.body && ts.isBlock(cb.body)) {
|
|
397
729
|
const stmts = cb.body.statements;
|
|
398
730
|
const line = lineOf(sf, node);
|
|
399
|
-
// C20: an assertion
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
731
|
+
// C20: an assertion at a position control can never reach (after a
|
|
732
|
+
// return/throw/process.exit/break, both-arms-terminating if, exhaustive
|
|
733
|
+
// switch). Structured reachability over the whole body, not just the top
|
|
734
|
+
// level; stops at nested functions (their returns are their own).
|
|
735
|
+
const deadAsserts = assertionsInDeadCode(cb.body, isAssertionNode);
|
|
736
|
+
for (const a of deadAsserts) {
|
|
737
|
+
push(lineOf(sf, a), "C20", "assertion in unreachable code (after a return/throw/exit) never runs");
|
|
738
|
+
}
|
|
739
|
+
const deadAssertSet = new Set(deadAsserts);
|
|
740
|
+
// C48: dark patch — the test flips a known test-mode flag (process.env or a
|
|
741
|
+
// module/settings flag) into test mode and then asserts, exercising the
|
|
742
|
+
// product's test-only branch instead of real behaviour. v1: raw writes only.
|
|
743
|
+
const toggles = [];
|
|
744
|
+
const assertPositions = [];
|
|
745
|
+
forEachNoNesting(cb.body, (n) => {
|
|
746
|
+
if (ts.isBinaryExpression(n) && isTestModeToggleWrite(n)) {
|
|
747
|
+
toggles.push({ pos: n.getStart(sf), line: lineOf(sf, n) });
|
|
748
|
+
}
|
|
749
|
+
if (isAssertionNode(n))
|
|
750
|
+
assertPositions.push(n.getStart(sf));
|
|
751
|
+
});
|
|
752
|
+
for (const w of toggles) {
|
|
753
|
+
if (assertPositions.some((ap) => ap > w.pos)) {
|
|
754
|
+
push(w.line, "C48", "test sets a test-mode flag then asserts — drive real behaviour, not the test-only branch");
|
|
404
755
|
}
|
|
405
|
-
if (ts.isReturnStatement(st) || ts.isThrowStatement(st))
|
|
406
|
-
terminated = true;
|
|
407
756
|
}
|
|
408
757
|
if (stmts.length === 0) {
|
|
409
758
|
push(line, "C2", "test body is empty");
|
|
@@ -414,12 +763,62 @@ export function analyze(sf) {
|
|
|
414
763
|
else if (hasAssertion(cb)) {
|
|
415
764
|
const ms = matchersUnder(cb);
|
|
416
765
|
if (ms.length > 0 && ms.every((m) => SNAPSHOT_MATCHERS.has(m))) {
|
|
417
|
-
push(line, "JS3",
|
|
766
|
+
push(line, "JS3", hasEmptyInlineSnapshot(cb)
|
|
767
|
+
? "the only assertion is an empty inline snapshot — it passes by writing itself on first run"
|
|
768
|
+
: "the only assertion is a snapshot");
|
|
418
769
|
}
|
|
419
|
-
|
|
770
|
+
// C21: the test has at least one assertion in its own scope and none of
|
|
771
|
+
// them is guaranteed to run unconditionally (all behind a condition, a
|
|
772
|
+
// loop, a switch, or a catch). Assertions that live only inside a nested
|
|
773
|
+
// callback are not counted here (unmodeled execution → suppress, FP-averse).
|
|
774
|
+
// Assertions already flagged C20 (dead code) are excluded: C20 owns them, so
|
|
775
|
+
// a dead-code-only test reports C20 alone, not a contradictory C20 + C21 (#62).
|
|
776
|
+
const ownAsserts = [];
|
|
777
|
+
forEachNoNesting(cb.body, (n) => {
|
|
778
|
+
if (isAssertionNode(n) && !deadAssertSet.has(n))
|
|
779
|
+
ownAsserts.push(n);
|
|
780
|
+
});
|
|
781
|
+
if (ownAsserts.length > 0 &&
|
|
782
|
+
!hasUnconditionalAssertion(cb.body, isAssertionNode, literalTruthiness, deadAssertSet)) {
|
|
420
783
|
push(line, "C21", "every assertion is guarded by a condition");
|
|
421
784
|
}
|
|
422
785
|
}
|
|
786
|
+
// JS23: expect.assertions(N) with N a numeric literal, but fewer
|
|
787
|
+
// unconditional reachable non-nested expect() matcher calls than N — the
|
|
788
|
+
// guard can never be satisfied. FP-averse: any expect in a loop, branch,
|
|
789
|
+
// callback, or helper is indeterminate, so the count is undercounted and a
|
|
790
|
+
// shortfall is only reported when it is provable. expect.hasAssertions()
|
|
791
|
+
// carries no count and is skipped. Distinct from the deliberately-skipped JS16.
|
|
792
|
+
let assertionsGuard = null;
|
|
793
|
+
forEachNoNesting(cb.body, (n) => {
|
|
794
|
+
if (ts.isCallExpression(n)) {
|
|
795
|
+
const cnt = expectAssertionsCount(n);
|
|
796
|
+
if (cnt !== null)
|
|
797
|
+
assertionsGuard = { line: lineOf(sf, n), n: cnt };
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
if (assertionsGuard !== null) {
|
|
801
|
+
const guard = assertionsGuard;
|
|
802
|
+
const acc = expectAccounting(cb.body);
|
|
803
|
+
if (!acc.indeterminate && acc.unconditional < guard.n) {
|
|
804
|
+
push(guard.line, "JS23", `expect.assertions(${guard.n}) but only ${acc.unconditional} unconditional expect() call(s) run`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
// JS8 (spyOn form): test-local. A spyOn target with a canned return that is
|
|
808
|
+
// also an expect subject IN THE SAME test body is a self-mock — the test
|
|
809
|
+
// asserts the canned value. Scoped to cb.body so a spy in one test never
|
|
810
|
+
// matches an assertion in another (the jest.mock module form, hoisted
|
|
811
|
+
// file-wide, is handled separately after the walk).
|
|
812
|
+
const spied = cannedSpyTargets(cb.body);
|
|
813
|
+
if (spied.size > 0) {
|
|
814
|
+
const subjects = expectSubjectRoots(cb.body);
|
|
815
|
+
for (const [tr, ln] of spied) {
|
|
816
|
+
if (subjects.has(tr)) {
|
|
817
|
+
push(ln, "JS8", `${tr} is spied with a canned return and asserted directly`);
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
423
822
|
// D7: anonymous test (empty or missing description)
|
|
424
823
|
const desc = node.arguments[0];
|
|
425
824
|
const emptyStr = (d) => d !== undefined &&
|
|
@@ -460,7 +859,7 @@ export function analyze(sf) {
|
|
|
460
859
|
}
|
|
461
860
|
}
|
|
462
861
|
// JS6: empty describe/suite block
|
|
463
|
-
if (SUITE_ROOTS.has(root)
|
|
862
|
+
if (SUITE_ROOTS.has(root)) {
|
|
464
863
|
const cb = getTestCallback(node);
|
|
465
864
|
if (cb && cb.body && ts.isBlock(cb.body) && cb.body.statements.length === 0) {
|
|
466
865
|
push(lineOf(sf, node), "JS6", "suite body is empty");
|
|
@@ -507,6 +906,21 @@ export function analyze(sf) {
|
|
|
507
906
|
if (subjIsComparison && boolMatcher) {
|
|
508
907
|
push(lineOf(sf, node), "JS15", "comparison wrapped in a boolean; assert the values directly");
|
|
509
908
|
}
|
|
909
|
+
// C44 numeric tautology: `expect(x.length).toBeGreaterThanOrEqual(0)`.
|
|
910
|
+
// A `.length` is never negative and never NaN, so `>= 0` holds for every
|
|
911
|
+
// input and verifies nothing — the JS/TS mirror of the Python `len(x) >= 0`.
|
|
912
|
+
// The subject must be a DIRECT property access ending in `.length`: a derived
|
|
913
|
+
// expression that merely mentions `.length` (e.g. `a.length - b.length`) can
|
|
914
|
+
// be negative, so it is a real check and is not flagged. Bounds that can still
|
|
915
|
+
// fail (`>= 1`, `> 0`) are not tautologies either. Finiteness/NaN guards
|
|
916
|
+
// (`toBeLessThan(Infinity)`, `toBeGreaterThan(-Infinity)`) are intentionally
|
|
917
|
+
// NOT flagged: they are false for NaN (and `Infinity`), so they catch
|
|
918
|
+
// divide-by-zero and invalid-number bugs.
|
|
919
|
+
if (chain.matcher === "toBeGreaterThanOrEqual" &&
|
|
920
|
+
arg && ts.isNumericLiteral(arg) && Number(arg.text) === 0 &&
|
|
921
|
+
subj && ts.isPropertyAccessExpression(subj) && subj.name.text === "length") {
|
|
922
|
+
push(lineOf(sf, node), "C44", "length is never negative; this comparison is always true");
|
|
923
|
+
}
|
|
510
924
|
// D8 (diagnostic, opt-in): a magic integer literal as the expected value.
|
|
511
925
|
// Floats are C8's concern; D8 covers bare integers abs > 1.
|
|
512
926
|
if ((chain.matcher === "toBe" || chain.matcher === "toEqual" || chain.matcher === "toStrictEqual") &&
|
|
@@ -576,18 +990,33 @@ export function analyze(sf) {
|
|
|
576
990
|
push(lineOf(sf, node), "C23", "hard-coded URL (mystery guest)");
|
|
577
991
|
}
|
|
578
992
|
}
|
|
579
|
-
// JS5: async query/event
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
993
|
+
// JS5: async query/event whose settled state is dropped. Detection runs
|
|
994
|
+
// through the oracle registry: a `promise` or `value-only` call (Testing
|
|
995
|
+
// Library findBy*/waitFor*, user-event, Vue/Svelte flushPromises/nextTick/
|
|
996
|
+
// tick) that is not awaited, returned, assigned, or `void`-discarded leaves
|
|
997
|
+
// the following assertion reading a stale moment. The Vue/Svelte helpers are
|
|
998
|
+
// only their promise form when called with no callback argument; nextTick(cb)
|
|
999
|
+
// is the callback form and settles on its own.
|
|
1000
|
+
{
|
|
1001
|
+
const kind = oracleKind(name);
|
|
1002
|
+
const leaf = name.split(".").pop() ?? "";
|
|
1003
|
+
const isVueSvelte = VUE_SVELTE_ASYNC.has(leaf);
|
|
1004
|
+
const promiseForm = !isVueSvelte || node.arguments.length === 0;
|
|
1005
|
+
if ((kind === "promise" || kind === "value-only") && promiseForm && !isObservedAsync(node)) {
|
|
1006
|
+
push(lineOf(sf, node), "JS5", isVueSvelte ? `${leaf}() is not awaited` : `${name} is not awaited`);
|
|
1007
|
+
}
|
|
589
1008
|
}
|
|
590
|
-
// JS7: assertion
|
|
1009
|
+
// JS7: a deferred assertion. One code, two mechanisms tagged in `detail`:
|
|
1010
|
+
// timer arm — assertion in a setTimeout/setInterval callback that is
|
|
1011
|
+
// never flushed. Suppression is precise (timerIsControlled):
|
|
1012
|
+
// scoped to the enclosing it/test callback and order-aware —
|
|
1013
|
+
// a fake-timer install BEFORE the arm, or a flush/advance
|
|
1014
|
+
// AFTER it, in the same callback. A flush before the arm
|
|
1015
|
+
// (callback never ran) or a flush in a different test does
|
|
1016
|
+
// not count, so it still runs after the test reports green.
|
|
1017
|
+
// promise arm — assertion in a floating .then/.catch/.finally (the call
|
|
1018
|
+
// is a bare statement, not awaited/returned/chained), so it
|
|
1019
|
+
// may not run before the test ends.
|
|
591
1020
|
{
|
|
592
1021
|
const leaf = name.split(".").pop() ?? "";
|
|
593
1022
|
const isTimer = name === "setTimeout" || name === "setInterval";
|
|
@@ -596,11 +1025,11 @@ export function analyze(sf) {
|
|
|
596
1025
|
const cb = node.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
|
597
1026
|
if (cb && containsAssertion(cb)) {
|
|
598
1027
|
const awaitedOrChained = !ts.isExpressionStatement(node.parent);
|
|
599
|
-
if (isTimer && !
|
|
600
|
-
push(lineOf(sf, node), "JS7", `assertion
|
|
1028
|
+
if (isTimer && !timerIsControlled(node, sf)) {
|
|
1029
|
+
push(lineOf(sf, node), "JS7", `assertion deferred into ${name}; runs after the test ends`);
|
|
601
1030
|
}
|
|
602
1031
|
else if (isThen && !awaitedOrChained) {
|
|
603
|
-
push(lineOf(sf, node), "JS7", `assertion
|
|
1032
|
+
push(lineOf(sf, node), "JS7", `assertion deferred into a floating .${leaf}(); may not run before the test ends`);
|
|
604
1033
|
}
|
|
605
1034
|
}
|
|
606
1035
|
}
|
|
@@ -615,9 +1044,18 @@ export function analyze(sf) {
|
|
|
615
1044
|
const isVueComponentQuery = qleaf === "findComponent" || qleaf === "findAllComponents";
|
|
616
1045
|
const isVueSelectorQuery = (qleaf === "find" || qleaf === "findAll") &&
|
|
617
1046
|
node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0]);
|
|
618
|
-
|
|
1047
|
+
// A cy-rooted chain belongs to JS24, not JS13: cy.get("ul").find("li") ends in
|
|
1048
|
+
// .find("li") and would otherwise trip the Vue-selector heuristic (double-report).
|
|
1049
|
+
if ((isRtlQuery || isVueComponentQuery || isVueSelectorQuery) && rootIdent(node) !== "cy") {
|
|
619
1050
|
push(lineOf(sf, node), "JS13", `${qleaf}() result is not asserted`);
|
|
620
1051
|
}
|
|
1052
|
+
// JS24: the cy.* analogue of JS13 — a Cypress query chain (cy.get/find/
|
|
1053
|
+
// contains) as a statement with no terminating .should/.and and no expect
|
|
1054
|
+
// in a .then callback. Only query commands produce a subject; action
|
|
1055
|
+
// commands (click/type/visit/...) do work and stay clean.
|
|
1056
|
+
if (isUnassertedCyQuery(node)) {
|
|
1057
|
+
push(lineOf(sf, node), "JS24", `cy query (${qleaf}) result is not asserted`);
|
|
1058
|
+
}
|
|
621
1059
|
}
|
|
622
1060
|
// A runner `.each` table: it.each / test.each / describe.each (and fit/xit
|
|
623
1061
|
// variants). Gate the each-table codes on a runner root so a plain helper
|
|
@@ -681,6 +1119,16 @@ export function analyze(sf) {
|
|
|
681
1119
|
expectRooted(node.expression)) {
|
|
682
1120
|
push(lineOf(sf, node), "JS21", `${node.name.text} is referenced but never called`);
|
|
683
1121
|
}
|
|
1122
|
+
// C16: `new Date()` with no argument reads the system clock (nondeterministic).
|
|
1123
|
+
// `new Date(literal)` / `new Date(expr)` constructs a fixed instant, so it stays
|
|
1124
|
+
// clean; the file-wide fake-timer suppression applies here too.
|
|
1125
|
+
if (!fakeTimers &&
|
|
1126
|
+
ts.isNewExpression(node) &&
|
|
1127
|
+
ts.isIdentifier(node.expression) &&
|
|
1128
|
+
node.expression.text === "Date" &&
|
|
1129
|
+
(node.arguments === undefined || node.arguments.length === 0)) {
|
|
1130
|
+
push(lineOf(sf, node), "C16", "new Date() reads the system clock");
|
|
1131
|
+
}
|
|
684
1132
|
ts.forEachChild(node, visit);
|
|
685
1133
|
};
|
|
686
1134
|
visit(sf);
|