test-inventory 0.1.1 → 0.2.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 +30 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +363 -57
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +113 -49
- package/dist/frameworks/playwright.js +59 -22
- package/dist/frameworks/vitest.d.ts +2 -0
- package/dist/frameworks/vitest.js +91 -12
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +9 -3
- package/dist/options.d.ts +2 -0
- package/dist/records.d.ts +1 -0
- package/dist/records.js +29 -7
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +227 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +34 -3
- package/dist/values.d.ts +14 -0
- package/dist/values.js +44 -3
- package/package.json +12 -9
- package/schema/v1.json +48 -1
package/dist/extract.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { childNodes, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
|
|
1
|
+
import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
|
|
2
2
|
import { exportName } from "./frameworks/bindings.js";
|
|
3
3
|
import { problem } from "./frameworks/common.js";
|
|
4
4
|
import { snippet, splitLines } from "./source.js";
|
|
5
5
|
import { isScopeNode } from "./values.js";
|
|
6
|
+
/** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
|
|
7
|
+
const MAX_DEPTH = 16;
|
|
8
|
+
/**
|
|
9
|
+
* How many helper calls are followed in one file, and from each call in a test or hook, counting the calls inside the
|
|
10
|
+
* helpers; far more than real suites make. Helpers that call each other in several places multiply: ten calls at each
|
|
11
|
+
* of six levels are a million.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_CALLS = 10_000;
|
|
6
14
|
/** Whether a function is called where it is written, so that its tests are registered there. */
|
|
7
15
|
function calledWhereWritten(call, written) {
|
|
8
16
|
return (call.scope === written.scope &&
|
|
@@ -12,6 +20,12 @@ function calledWhereWritten(call, written) {
|
|
|
12
20
|
!call.inBody);
|
|
13
21
|
}
|
|
14
22
|
const LOOP_METHODS = new Set(['forEach', 'map']);
|
|
23
|
+
/** What `test.describe.configure()` sets on the describe or file it is called in: the last mode wins, locks add up. */
|
|
24
|
+
function configure(scope, settings) {
|
|
25
|
+
if (settings.mode)
|
|
26
|
+
scope.mode = settings.mode;
|
|
27
|
+
scope.details.locks.push(...settings.locks);
|
|
28
|
+
}
|
|
15
29
|
/** Joins conditions with `&&`, adding parentheses where a part could otherwise be read differently. */
|
|
16
30
|
function joinConditions(parts) {
|
|
17
31
|
if (parts.length <= 1)
|
|
@@ -33,21 +47,46 @@ class Walker {
|
|
|
33
47
|
suites = [];
|
|
34
48
|
tests = [];
|
|
35
49
|
problems = [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
/** The scanned file. */
|
|
51
|
+
main;
|
|
52
|
+
/** The file whose code is being walked: the scanned file, or the module of an imported helper. */
|
|
53
|
+
frame;
|
|
54
|
+
/** While an imported helper is walked, the call in the scanned file that walks it; records and problems point there. */
|
|
55
|
+
site = null;
|
|
56
|
+
openModule;
|
|
57
|
+
frames = new Map();
|
|
58
|
+
/** Problems of helper modules' own imports, reported once, at the first call that walks the module. */
|
|
59
|
+
moduleProblems = new Set();
|
|
60
|
+
/** The file each describe is written in, so that a describe's body hash only leaves out code from its own file. */
|
|
61
|
+
suiteFrames = new WeakMap();
|
|
39
62
|
effects = new Map();
|
|
40
63
|
helperCalls = [];
|
|
41
64
|
/** Where each function with a name is written, outside test and hook bodies. */
|
|
42
65
|
written = new Map();
|
|
43
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Every call by name to a function declared in the file, once per call: a call inside a helper is walked again each
|
|
68
|
+
* time the helper is, and the first walk, from where the helper is called, places it.
|
|
69
|
+
*/
|
|
44
70
|
callSites = new Map();
|
|
45
71
|
/** The function with a name each test is declared in, which registers the test when it is called. */
|
|
46
72
|
declaredIn = new Map();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
deferred = [];
|
|
74
|
+
isDeferred = new Set();
|
|
75
|
+
/** Functions walked at one of their calls, so that their tests are recorded where they are registered. */
|
|
76
|
+
walkedAtCall = new Set();
|
|
77
|
+
/** The functions being walked at their calls, innermost last, to stop recursion. */
|
|
78
|
+
callStack = [];
|
|
79
|
+
/** Helper calls walked in the file so far, up to MAX_CALLS. */
|
|
80
|
+
callsWalked = 0;
|
|
81
|
+
/** Problems already reported, since a function walked at several calls finds the same ones each time. */
|
|
82
|
+
reported = new Set();
|
|
83
|
+
/** For each file's program, the scopes around each function in it. */
|
|
84
|
+
scopeChains = new Map();
|
|
85
|
+
constructor(main, openModule) {
|
|
86
|
+
this.main = main;
|
|
87
|
+
this.frame = main;
|
|
88
|
+
this.openModule = openModule;
|
|
89
|
+
const { adapter } = main;
|
|
51
90
|
this.fileScope = {
|
|
52
91
|
kind: 'file',
|
|
53
92
|
parent: null,
|
|
@@ -59,7 +98,7 @@ class Walker {
|
|
|
59
98
|
};
|
|
60
99
|
}
|
|
61
100
|
run() {
|
|
62
|
-
this.visit(this.parsed.program, {
|
|
101
|
+
this.visit(this.main.parsed.program, {
|
|
63
102
|
scope: this.fileScope,
|
|
64
103
|
test: null,
|
|
65
104
|
hookScope: null,
|
|
@@ -70,15 +109,23 @@ class Walker {
|
|
|
70
109
|
isInFunction: false,
|
|
71
110
|
helper: null,
|
|
72
111
|
declaredIn: null,
|
|
112
|
+
quiet: false,
|
|
113
|
+
registeredAt: null,
|
|
73
114
|
});
|
|
74
|
-
|
|
115
|
+
// Functions defined inside these are deferred in turn; the loop reaches them too, as they are added to the end.
|
|
116
|
+
for (const entry of this.deferred)
|
|
117
|
+
this.walkWhereWritten(entry);
|
|
118
|
+
// Records follow the order the file registers them in; the sort is stable, so one call's tests keep theirs.
|
|
119
|
+
this.tests.sort((a, b) => a.order - b.order);
|
|
120
|
+
this.suites.sort((a, b) => a.order - b.order);
|
|
121
|
+
const exported = this.exportedFunctions(this.main.parsed.program);
|
|
75
122
|
this.applyHelpers(exported);
|
|
76
123
|
this.checkFunctionTests(exported);
|
|
77
124
|
return {
|
|
78
125
|
suites: this.suites,
|
|
79
126
|
tests: this.tests,
|
|
80
|
-
problems: [...this.adapter.problems, ...this.problems],
|
|
81
|
-
usesGlobals: this.adapter.usesGlobals,
|
|
127
|
+
problems: [...this.main.adapter.problems, ...this.problems],
|
|
128
|
+
usesGlobals: this.main.adapter.usesGlobals,
|
|
82
129
|
};
|
|
83
130
|
}
|
|
84
131
|
visit(node, context) {
|
|
@@ -97,11 +144,15 @@ class Walker {
|
|
|
97
144
|
}
|
|
98
145
|
visitNode(node, context) {
|
|
99
146
|
switch (node.type) {
|
|
100
|
-
case 'CallExpression':
|
|
101
|
-
this.helperCall(node, context);
|
|
147
|
+
case 'CallExpression': {
|
|
148
|
+
const helper = this.helperCall(node, context);
|
|
102
149
|
if (this.call(node, context) || this.loopCall(node, context))
|
|
103
150
|
return;
|
|
104
|
-
|
|
151
|
+
this.visitChildren(node, context);
|
|
152
|
+
if (helper)
|
|
153
|
+
this.walkAtCall(helper, node, context);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
105
156
|
case 'IfStatement': {
|
|
106
157
|
// In-source tests sit inside `if (import.meta.vitest)`, which is how they are written, not a condition.
|
|
107
158
|
if (isImportMetaVitest(node.test))
|
|
@@ -152,14 +203,23 @@ class Walker {
|
|
|
152
203
|
// A function outside any test or hook runs when it is called; what it sets applies where it is called.
|
|
153
204
|
const helper = context.test || context.hookScope ? null : node;
|
|
154
205
|
const named = helper !== null && this.isNamed(node);
|
|
155
|
-
|
|
206
|
+
// In an imported helper's module, a function runs only when it is called, and is walked then.
|
|
207
|
+
if (named && this.frame !== this.main)
|
|
208
|
+
return;
|
|
209
|
+
if (named && !this.isDeferred.has(node)) {
|
|
210
|
+
// Its calls decide where its tests are registered, and a call may come after it, so it waits.
|
|
156
211
|
this.written.set(node, { ...this.placement(context), declaredIn: context.declaredIn });
|
|
212
|
+
this.isDeferred.add(node);
|
|
213
|
+
this.deferred.push({ fn: node, context, scopes: [...this.scopes], parents: [...this.parents] });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (named)
|
|
217
|
+
return;
|
|
157
218
|
this.visitChildren(node, {
|
|
158
219
|
...context,
|
|
159
220
|
isInFunction: true,
|
|
160
221
|
helper: helper ?? context.helper,
|
|
161
222
|
guards: helper ? [] : context.guards,
|
|
162
|
-
declaredIn: named ? node : context.declaredIn,
|
|
163
223
|
});
|
|
164
224
|
return;
|
|
165
225
|
}
|
|
@@ -196,7 +256,7 @@ class Walker {
|
|
|
196
256
|
this.scopes.pop();
|
|
197
257
|
}
|
|
198
258
|
text(node) {
|
|
199
|
-
return snippet(this.parsed.source.slice(node.start, node.end));
|
|
259
|
+
return snippet(this.frame.parsed.source.slice(node.start, node.end));
|
|
200
260
|
}
|
|
201
261
|
/** `symbols.forEach((symbol) => test(...))` declares its tests in a loop. */
|
|
202
262
|
loopCall(call, context) {
|
|
@@ -215,7 +275,7 @@ class Walker {
|
|
|
215
275
|
return true;
|
|
216
276
|
}
|
|
217
277
|
call(call, context) {
|
|
218
|
-
const api = this.adapter.classify(call, {
|
|
278
|
+
const api = this.frame.adapter.classify(call, {
|
|
219
279
|
scopes: this.scopes,
|
|
220
280
|
testContext: context.testContext,
|
|
221
281
|
inTest: context.test !== null,
|
|
@@ -234,7 +294,11 @@ class Walker {
|
|
|
234
294
|
this.declareSuite(call, api, context);
|
|
235
295
|
return true;
|
|
236
296
|
case 'modifier': {
|
|
237
|
-
(context.test
|
|
297
|
+
if (context.test)
|
|
298
|
+
context.test.problems.push(...api.problems.map((item) => this.here(item)));
|
|
299
|
+
else
|
|
300
|
+
for (const item of api.problems)
|
|
301
|
+
this.report(item);
|
|
238
302
|
if (api.condition.applies === 'never')
|
|
239
303
|
return true;
|
|
240
304
|
const own = api.condition.applies === 'sometimes' ? [api.condition.text] : [];
|
|
@@ -243,7 +307,7 @@ class Walker {
|
|
|
243
307
|
condition: joinConditions([...context.guards, ...own]),
|
|
244
308
|
reason: api.reason,
|
|
245
309
|
hasDynamicReason: api.hasDynamicReason,
|
|
246
|
-
line: this.
|
|
310
|
+
line: this.lineOf(call),
|
|
247
311
|
};
|
|
248
312
|
if (context.helper)
|
|
249
313
|
this.effectsOf(context.helper).push({ kind: 'state', setting, call });
|
|
@@ -251,11 +315,15 @@ class Walker {
|
|
|
251
315
|
(context.test ?? context.hookScope ?? context.scope).states.push(setting);
|
|
252
316
|
return true;
|
|
253
317
|
}
|
|
254
|
-
case '
|
|
255
|
-
|
|
256
|
-
this.
|
|
257
|
-
|
|
258
|
-
context.
|
|
318
|
+
case 'configure':
|
|
319
|
+
for (const item of api.problems)
|
|
320
|
+
this.report(item);
|
|
321
|
+
if (context.helper) {
|
|
322
|
+
this.effectsOf(context.helper).push({ kind: 'configure', mode: api.mode, locks: api.locks, call });
|
|
323
|
+
}
|
|
324
|
+
else if (!context.test && !context.hookScope) {
|
|
325
|
+
configure(context.scope, api);
|
|
326
|
+
}
|
|
259
327
|
return true;
|
|
260
328
|
case 'hook':
|
|
261
329
|
if (context.test)
|
|
@@ -272,11 +340,11 @@ class Walker {
|
|
|
272
340
|
return true;
|
|
273
341
|
case 'annotations':
|
|
274
342
|
context.test?.annotations.push(...api.items);
|
|
275
|
-
context.test?.problems.push(...api.problems);
|
|
343
|
+
context.test?.problems.push(...api.problems.map((item) => this.here(item)));
|
|
276
344
|
return true;
|
|
277
345
|
case 'invalid': {
|
|
278
346
|
const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
|
|
279
|
-
this.
|
|
347
|
+
this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
|
|
280
348
|
return true;
|
|
281
349
|
}
|
|
282
350
|
case 'other':
|
|
@@ -290,29 +358,239 @@ class Walker {
|
|
|
290
358
|
}
|
|
291
359
|
/** The function a name refers to when it is declared in the file, as a function or a variable holding one. */
|
|
292
360
|
functionNamed(name, scopes) {
|
|
293
|
-
|
|
361
|
+
return this.functionIn(this.frame, name, scopes);
|
|
362
|
+
}
|
|
363
|
+
functionIn(frame, name, scopes) {
|
|
364
|
+
const declaration = frame.values.declarationOf(name, scopes);
|
|
294
365
|
if (declaration?.type === 'FunctionDeclaration')
|
|
295
366
|
return declaration;
|
|
296
367
|
const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
|
|
297
368
|
return init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression' ? init : null;
|
|
298
369
|
}
|
|
299
|
-
/**
|
|
370
|
+
/**
|
|
371
|
+
* Records a call to a function declared in the file. A call from the file or a describe returns the function, to be
|
|
372
|
+
* walked there: its tests are registered in the describe around the call, and what it sets applies there. A call
|
|
373
|
+
* from a test, a hook or another function is applied later, through the states the function sets.
|
|
374
|
+
*/
|
|
300
375
|
helperCall(call, context) {
|
|
301
376
|
const callee = unwrap(call.callee);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
377
|
+
if (callee.type !== 'Identifier')
|
|
378
|
+
return null;
|
|
379
|
+
const fn = this.functionNamed(callee.name, this.scopes);
|
|
380
|
+
if (!fn) {
|
|
381
|
+
const runsHere = !context.test && !context.hookScope && !context.helper;
|
|
382
|
+
const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name) : null;
|
|
383
|
+
return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
|
|
384
|
+
}
|
|
385
|
+
const sites = this.callSites.get(fn) ?? new Map();
|
|
386
|
+
if (!sites.has(call))
|
|
387
|
+
sites.set(call, this.placement(context));
|
|
307
388
|
this.callSites.set(fn, sites);
|
|
308
389
|
if (context.helper) {
|
|
309
390
|
this.effectsOf(context.helper).push({ kind: 'call', fn, guards: context.guards });
|
|
310
391
|
}
|
|
311
|
-
else {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
392
|
+
else if (context.test || context.hookScope) {
|
|
393
|
+
this.helperCalls.push({
|
|
394
|
+
call,
|
|
395
|
+
fn,
|
|
396
|
+
target: context.test ?? context.hookScope,
|
|
397
|
+
guards: context.guards,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
else if (!this.callStack.includes(fn) && this.callStack.length < MAX_DEPTH) {
|
|
401
|
+
return this.withinLimit({ fn, frame: this.frame }, call);
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
/** The function to walk at a call, while the file has calls left to walk; past MAX_CALLS, the call is reported once. */
|
|
406
|
+
withinLimit(callee, call) {
|
|
407
|
+
if (this.callsWalked < MAX_CALLS) {
|
|
408
|
+
this.callsWalked++;
|
|
409
|
+
return callee;
|
|
410
|
+
}
|
|
411
|
+
if (this.callsWalked === MAX_CALLS) {
|
|
412
|
+
this.callsWalked++;
|
|
413
|
+
this.report(problem('test-in-function', call, `The file's helper functions are called more than ${MAX_CALLS.toLocaleString('en-US')} times, counting the calls inside them, so the scan stops following them here. Tests they register from here on are missing, or listed once where they are written.`));
|
|
315
414
|
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
/** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
|
|
418
|
+
importedFunction(name) {
|
|
419
|
+
const declaration = this.frame.values.declarationOf(name, this.scopes);
|
|
420
|
+
const source = this.frame.values.importSource(name, this.scopes);
|
|
421
|
+
if (source === null)
|
|
422
|
+
return null;
|
|
423
|
+
const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
|
|
424
|
+
const frame = this.openFrom(this.frame, source);
|
|
425
|
+
return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
|
|
426
|
+
}
|
|
427
|
+
/** A module a file imports, read as a frame once; null when it can't be read or parsed. */
|
|
428
|
+
openFrom(frame, source) {
|
|
429
|
+
const module = frame.readModule?.(source);
|
|
430
|
+
if (!module || 'reason' in module)
|
|
431
|
+
return null;
|
|
432
|
+
if (!this.frames.has(module))
|
|
433
|
+
this.frames.set(module, this.openModule(module));
|
|
434
|
+
return this.frames.get(module) ?? null;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The function a module exports under a name, written there or re-exported from another module, as a barrel file
|
|
438
|
+
* does; null when the export isn't a function the scan can find. Each module is searched once for a name, however many
|
|
439
|
+
* barrels lead to it: one searched before didn't have it, or the search would have ended there.
|
|
440
|
+
*/
|
|
441
|
+
exportedFunction(frame, name, searched, depth) {
|
|
442
|
+
const key = `${frame.relativeFilePath}\0${name}`;
|
|
443
|
+
if (searched.has(key) || depth >= MAX_DEPTH)
|
|
444
|
+
return null;
|
|
445
|
+
searched.add(key);
|
|
446
|
+
const { program } = frame.parsed;
|
|
447
|
+
const local = (localName) => {
|
|
448
|
+
const fn = this.functionIn(frame, localName, [program]);
|
|
449
|
+
return fn ? { fn, frame } : null;
|
|
450
|
+
};
|
|
451
|
+
const from = (source, exported) => {
|
|
452
|
+
const module = this.openFrom(frame, source);
|
|
453
|
+
return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
|
|
454
|
+
};
|
|
455
|
+
for (const statement of program.body) {
|
|
456
|
+
if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
|
|
457
|
+
const declaration = statement.declaration;
|
|
458
|
+
if (declaration.type === 'FunctionDeclaration' || isFunctionNode(declaration))
|
|
459
|
+
return { fn: declaration, frame };
|
|
460
|
+
return declaration.type === 'Identifier' ? local(declaration.name) : null;
|
|
461
|
+
}
|
|
462
|
+
if (statement.type === 'ExportAllDeclaration' && !statement.exported) {
|
|
463
|
+
const found = from(statement.source.value, name);
|
|
464
|
+
if (found)
|
|
465
|
+
return found;
|
|
466
|
+
}
|
|
467
|
+
if (statement.type !== 'ExportNamedDeclaration')
|
|
468
|
+
continue;
|
|
469
|
+
const declaration = statement.declaration;
|
|
470
|
+
if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name === name)
|
|
471
|
+
return { fn: declaration, frame };
|
|
472
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
473
|
+
const declarator = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === name);
|
|
474
|
+
if (declarator)
|
|
475
|
+
return local(name);
|
|
476
|
+
}
|
|
477
|
+
const specifier = statement.specifiers.find((item) => exportName(item.exported) === name);
|
|
478
|
+
if (specifier) {
|
|
479
|
+
const localName = exportName(specifier.local);
|
|
480
|
+
return statement.source ? from(statement.source.value, localName) : local(localName);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
|
|
487
|
+
* where it is written, and its parameters bound to the call's arguments so that titles built from them resolve. A
|
|
488
|
+
* function imported from another module is walked in that module, and what it registers points at this call.
|
|
489
|
+
*/
|
|
490
|
+
walkAtCall({ fn, frame }, call, context) {
|
|
491
|
+
this.walkedAtCall.add(fn);
|
|
492
|
+
// Arguments are worked out where the call is, in the file and scopes of the caller.
|
|
493
|
+
const spread = call.arguments.findIndex((item) => item.type === 'SpreadElement');
|
|
494
|
+
const args = (spread === -1 ? call.arguments : call.arguments.slice(0, spread)).map((item) => this.frame.values.resolve(item, this.scopes));
|
|
495
|
+
const caller = this.frame;
|
|
496
|
+
const site = this.site;
|
|
497
|
+
if (frame !== caller) {
|
|
498
|
+
this.frame = frame;
|
|
499
|
+
this.site ??= call;
|
|
500
|
+
}
|
|
501
|
+
const scopes = this.scopeChain(fn);
|
|
502
|
+
const saved = this.scopes.splice(0, this.scopes.length, ...scopes);
|
|
503
|
+
this.callStack.push(fn);
|
|
504
|
+
frame.values.withArguments(fn, args, scopes, () => {
|
|
505
|
+
this.visitCallback(fn, {
|
|
506
|
+
...context,
|
|
507
|
+
isInFunction: true,
|
|
508
|
+
declaredIn: null,
|
|
509
|
+
registeredAt: context.registeredAt ?? call.start,
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
this.callStack.pop();
|
|
513
|
+
this.scopes.splice(0, this.scopes.length, ...saved);
|
|
514
|
+
// What the module's own imports couldn't resolve is reported at the call, as it is why tests may be missing.
|
|
515
|
+
if (frame !== caller) {
|
|
516
|
+
for (const item of frame.adapter.problems.filter((found) => !this.moduleProblems.has(found))) {
|
|
517
|
+
this.moduleProblems.add(item);
|
|
518
|
+
this.report(item);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
this.frame = caller;
|
|
522
|
+
this.site = site;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Walks a deferred function where it is written. One that was walked at its calls only collects the states it sets,
|
|
526
|
+
* for calls from tests and hooks; one that wasn't records its tests where they are written, as nothing places them.
|
|
527
|
+
*/
|
|
528
|
+
walkWhereWritten({ fn, context, scopes, parents }) {
|
|
529
|
+
const savedScopes = this.scopes.splice(0, this.scopes.length, ...scopes);
|
|
530
|
+
const savedParents = this.parents.splice(0, this.parents.length, ...parents);
|
|
531
|
+
this.visitChildren(fn, {
|
|
532
|
+
...context,
|
|
533
|
+
isInFunction: true,
|
|
534
|
+
helper: fn,
|
|
535
|
+
guards: [],
|
|
536
|
+
declaredIn: fn,
|
|
537
|
+
quiet: this.walkedAtCall.has(fn),
|
|
538
|
+
});
|
|
539
|
+
this.scopes.splice(0, this.scopes.length, ...savedScopes);
|
|
540
|
+
this.parents.splice(0, this.parents.length, ...savedParents);
|
|
541
|
+
}
|
|
542
|
+
/** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
|
|
543
|
+
scopeChain(fn) {
|
|
544
|
+
const { program } = this.frame.parsed;
|
|
545
|
+
let chains = this.scopeChains.get(program);
|
|
546
|
+
if (!chains) {
|
|
547
|
+
const found = new Map();
|
|
548
|
+
const around = [];
|
|
549
|
+
const visit = (node) => {
|
|
550
|
+
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
551
|
+
found.set(node, [...around]);
|
|
552
|
+
const isScope = isScopeNode(node);
|
|
553
|
+
if (isScope)
|
|
554
|
+
around.push(node);
|
|
555
|
+
for (const child of childNodes(node))
|
|
556
|
+
visit(child);
|
|
557
|
+
if (isScope)
|
|
558
|
+
around.pop();
|
|
559
|
+
};
|
|
560
|
+
visit(program);
|
|
561
|
+
chains = found;
|
|
562
|
+
this.scopeChains.set(program, chains);
|
|
563
|
+
}
|
|
564
|
+
return chains.get(fn);
|
|
565
|
+
}
|
|
566
|
+
/** Reports a problem not tied to a test, once, however many times a function with it is walked. */
|
|
567
|
+
report(found) {
|
|
568
|
+
const item = this.here(found);
|
|
569
|
+
// Problems found in code point at it.
|
|
570
|
+
const key = `${item.code}\0${item.node.start}\0${item.message}`;
|
|
571
|
+
if (this.reported.has(key))
|
|
572
|
+
return;
|
|
573
|
+
this.reported.add(key);
|
|
574
|
+
this.problems.push(item);
|
|
575
|
+
}
|
|
576
|
+
/** A problem found in an imported helper points at the call in the scanned file that walks the helper. */
|
|
577
|
+
here(item) {
|
|
578
|
+
return this.site ? { ...item, node: this.site } : item;
|
|
579
|
+
}
|
|
580
|
+
/** The line in the scanned file of code being walked: its own, or that of the call walking an imported helper. */
|
|
581
|
+
lineOf(node) {
|
|
582
|
+
return this.main.parsed.source.line((this.site ?? node).start);
|
|
583
|
+
}
|
|
584
|
+
/** Where an imported helper writes a test or describe; null for code in the scanned file. */
|
|
585
|
+
declaredAt(call) {
|
|
586
|
+
if (!this.site)
|
|
587
|
+
return null;
|
|
588
|
+
return { relativeFilePath: this.frame.relativeFilePath, source: this.frame.parsed.source, start: call.start };
|
|
589
|
+
}
|
|
590
|
+
/** Leaves a test or describe out of the describe's body hash, when both are written in the same file. */
|
|
591
|
+
cut(scope, call) {
|
|
592
|
+
if (scope.suite && this.suiteFrames.get(scope.suite) === this.frame)
|
|
593
|
+
scope.suite.cuts.push(this.statement(call));
|
|
316
594
|
}
|
|
317
595
|
/** Functions the file exports, whose callers in other files are not followed. */
|
|
318
596
|
exportedFunctions(program) {
|
|
@@ -348,32 +626,46 @@ class Walker {
|
|
|
348
626
|
*/
|
|
349
627
|
applyHelpers(exported) {
|
|
350
628
|
const reached = new Set();
|
|
629
|
+
// For one call from a test or hook: the conditions each helper was expanded under, and how many expansions it took.
|
|
630
|
+
let expanded = new Map();
|
|
631
|
+
let steps = 0;
|
|
351
632
|
const expand = (fn, guards, path) => {
|
|
352
633
|
if (path.has(fn) || exported.has(fn))
|
|
353
634
|
return [];
|
|
635
|
+
// A helper reached again under the same conditions sets the same states again.
|
|
636
|
+
const conditions = expanded.get(fn) ?? new Set();
|
|
637
|
+
const key = guards.join('\0');
|
|
638
|
+
if (conditions.has(key) || ++steps > MAX_CALLS)
|
|
639
|
+
return [];
|
|
640
|
+
expanded.set(fn, conditions.add(key));
|
|
354
641
|
reached.add(fn);
|
|
355
642
|
return (this.effects.get(fn) ?? []).flatMap((effect) => {
|
|
356
643
|
if (effect.kind === 'call')
|
|
357
644
|
return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
|
|
358
|
-
if (effect.kind === '
|
|
645
|
+
if (effect.kind === 'configure')
|
|
359
646
|
return [effect];
|
|
360
647
|
const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
|
|
361
648
|
return [{ ...effect, setting: { ...effect.setting, condition } }];
|
|
362
649
|
});
|
|
363
650
|
};
|
|
364
651
|
for (const call of this.helperCalls) {
|
|
652
|
+
expanded = new Map();
|
|
653
|
+
steps = 0;
|
|
365
654
|
for (const effect of expand(call.fn, call.guards, new Set())) {
|
|
655
|
+
// `describe.configure()` does nothing in a test or hook body.
|
|
366
656
|
if (effect.kind === 'state')
|
|
367
657
|
call.target.states.push(effect.setting);
|
|
368
|
-
|
|
369
|
-
|
|
658
|
+
}
|
|
659
|
+
if (steps > MAX_CALLS) {
|
|
660
|
+
const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
|
|
661
|
+
this.problems.push(problem('state-in-helper', call.call, `${callee}() calls helper functions more than ${MAX_CALLS.toLocaleString('en-US')} times, counting the calls inside them, so the states they set past that are not applied.`, callee));
|
|
370
662
|
}
|
|
371
663
|
}
|
|
372
664
|
for (const [fn, effects] of this.effects) {
|
|
373
665
|
const own = effects.find((effect) => effect.kind !== 'call');
|
|
374
|
-
if (!own || (reached.has(fn) && !exported.has(fn)))
|
|
666
|
+
if (!own || ((reached.has(fn) || this.walkedAtCall.has(fn)) && !exported.has(fn)))
|
|
375
667
|
continue;
|
|
376
|
-
const callee = snippet(this.parsed.source.slice(own.call.callee.start, own.call.callee.end));
|
|
668
|
+
const callee = snippet(this.frame.parsed.source.slice(own.call.callee.start, own.call.callee.end));
|
|
377
669
|
const why = exported.has(fn) ? 'is exported' : 'no test, hook or describe in the file calls';
|
|
378
670
|
this.problems.push(problem('state-in-helper', own.call, `${callee}() is in a function that ${why}; it applies to the tests that call the function, so it is not applied here.`, callee));
|
|
379
671
|
}
|
|
@@ -382,7 +674,7 @@ class Walker {
|
|
|
382
674
|
misplaced(fn, exported) {
|
|
383
675
|
if (exported.has(fn))
|
|
384
676
|
return 'the file exports the function';
|
|
385
|
-
const sites = this.callSites.get(fn) ?? [];
|
|
677
|
+
const sites = [...(this.callSites.get(fn)?.values() ?? [])];
|
|
386
678
|
if (sites.length === 0)
|
|
387
679
|
return 'nothing in the file calls the function by name';
|
|
388
680
|
if (sites.length > 1)
|
|
@@ -410,29 +702,32 @@ class Walker {
|
|
|
410
702
|
nested(call, context) {
|
|
411
703
|
if (!context.test && !context.hookScope)
|
|
412
704
|
return false;
|
|
413
|
-
this.
|
|
705
|
+
this.report(problem('nested-test', call, 'A test or describe declared inside a test or hook body is not collected by the runner, so it is not listed.', snippet(this.frame.parsed.source.slice(call.start, call.end))));
|
|
414
706
|
return true;
|
|
415
707
|
}
|
|
416
708
|
declareTest(call, declaration, context) {
|
|
417
|
-
if (this.nested(call, context))
|
|
709
|
+
if (context.quiet || this.nested(call, context))
|
|
418
710
|
return;
|
|
419
711
|
const statement = this.statement(call);
|
|
420
712
|
const test = {
|
|
421
713
|
scope: context.scope,
|
|
422
714
|
declaration,
|
|
423
715
|
call,
|
|
716
|
+
site: this.site,
|
|
717
|
+
declaredAt: this.declaredAt(call),
|
|
718
|
+
order: context.registeredAt ?? call.start,
|
|
424
719
|
states: [...declaration.states],
|
|
425
720
|
annotations: [...declaration.details.annotations],
|
|
426
721
|
isInLoop: context.isInLoop,
|
|
427
722
|
isInCondition: context.isInCondition,
|
|
428
723
|
isInFunction: context.isInFunction,
|
|
429
724
|
comments: this.commentsAbove(statement.start),
|
|
430
|
-
problems:
|
|
725
|
+
problems: declaration.problems.map((item) => this.here(item)),
|
|
431
726
|
};
|
|
432
727
|
this.tests.push(test);
|
|
433
728
|
if (context.declaredIn)
|
|
434
729
|
this.declaredIn.set(test, context.declaredIn);
|
|
435
|
-
context.scope
|
|
730
|
+
this.cut(context.scope, call);
|
|
436
731
|
if (declaration.fn) {
|
|
437
732
|
this.visitCallback(declaration.fn, {
|
|
438
733
|
...context,
|
|
@@ -445,9 +740,10 @@ class Walker {
|
|
|
445
740
|
}
|
|
446
741
|
}
|
|
447
742
|
declareSuite(call, declaration, context) {
|
|
448
|
-
if (this.nested(call, context))
|
|
743
|
+
if (context.quiet || this.nested(call, context))
|
|
449
744
|
return;
|
|
450
|
-
|
|
745
|
+
for (const item of declaration.problems)
|
|
746
|
+
this.report(item);
|
|
451
747
|
const scope = {
|
|
452
748
|
kind: 'describe',
|
|
453
749
|
parent: context.scope,
|
|
@@ -457,10 +753,19 @@ class Walker {
|
|
|
457
753
|
details: declaration.details,
|
|
458
754
|
suite: null,
|
|
459
755
|
};
|
|
460
|
-
const suite = {
|
|
756
|
+
const suite = {
|
|
757
|
+
scope,
|
|
758
|
+
declaration,
|
|
759
|
+
call,
|
|
760
|
+
site: this.site,
|
|
761
|
+
declaredAt: this.declaredAt(call),
|
|
762
|
+
order: context.registeredAt ?? call.start,
|
|
763
|
+
cuts: [],
|
|
764
|
+
};
|
|
461
765
|
scope.suite = suite;
|
|
462
766
|
this.suites.push(suite);
|
|
463
|
-
context.scope
|
|
767
|
+
this.cut(context.scope, call);
|
|
768
|
+
this.suiteFrames.set(suite, this.frame);
|
|
464
769
|
if (declaration.fn) {
|
|
465
770
|
this.visitCallback(declaration.fn, {
|
|
466
771
|
...context,
|
|
@@ -480,7 +785,7 @@ class Walker {
|
|
|
480
785
|
}
|
|
481
786
|
/** Comment lines on the lines directly above a statement, with no blank line in between. */
|
|
482
787
|
commentsAbove(start) {
|
|
483
|
-
const { comments, source } = this.parsed;
|
|
788
|
+
const { comments, source } = this.frame.parsed;
|
|
484
789
|
const lines = [];
|
|
485
790
|
let next = start;
|
|
486
791
|
// Comments are in source order; find the last one that ends before the statement.
|
|
@@ -503,6 +808,7 @@ class Walker {
|
|
|
503
808
|
return lines;
|
|
504
809
|
}
|
|
505
810
|
}
|
|
506
|
-
|
|
507
|
-
|
|
811
|
+
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
812
|
+
export function extract(main, openModule) {
|
|
813
|
+
return new Walker(main, openModule).run();
|
|
508
814
|
}
|