test-inventory 0.1.2 → 0.2.1
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 +36 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +409 -52
- package/dist/files.d.ts +1 -1
- package/dist/files.js +5 -3
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +148 -79
- package/dist/frameworks/common.d.ts +12 -2
- package/dist/frameworks/common.js +24 -2
- package/dist/frameworks/playwright.d.ts +23 -4
- package/dist/frameworks/playwright.js +114 -54
- package/dist/frameworks/vitest.d.ts +25 -2
- package/dist/frameworks/vitest.js +94 -32
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +22 -1
- package/dist/options.d.ts +5 -2
- package/dist/records.js +24 -6
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +232 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +19 -1
- 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 &&
|
|
@@ -39,21 +47,48 @@ class Walker {
|
|
|
39
47
|
suites = [];
|
|
40
48
|
tests = [];
|
|
41
49
|
problems = [];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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();
|
|
45
62
|
effects = new Map();
|
|
46
63
|
helperCalls = [];
|
|
47
64
|
/** Where each function with a name is written, outside test and hook bodies. */
|
|
48
65
|
written = new Map();
|
|
49
|
-
/**
|
|
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
|
+
*/
|
|
50
70
|
callSites = new Map();
|
|
51
71
|
/** The function with a name each test is declared in, which registers the test when it is called. */
|
|
52
72
|
declaredIn = new Map();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
/** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
|
|
84
|
+
returns = new Map();
|
|
85
|
+
/** For each file's program, the scopes around each function in it. */
|
|
86
|
+
scopeChains = new Map();
|
|
87
|
+
constructor(main, openModule) {
|
|
88
|
+
this.main = main;
|
|
89
|
+
this.frame = main;
|
|
90
|
+
this.openModule = openModule;
|
|
91
|
+
const { adapter } = main;
|
|
57
92
|
this.fileScope = {
|
|
58
93
|
kind: 'file',
|
|
59
94
|
parent: null,
|
|
@@ -65,7 +100,7 @@ class Walker {
|
|
|
65
100
|
};
|
|
66
101
|
}
|
|
67
102
|
run() {
|
|
68
|
-
this.visit(this.parsed.program, {
|
|
103
|
+
this.visit(this.main.parsed.program, {
|
|
69
104
|
scope: this.fileScope,
|
|
70
105
|
test: null,
|
|
71
106
|
hookScope: null,
|
|
@@ -76,15 +111,23 @@ class Walker {
|
|
|
76
111
|
isInFunction: false,
|
|
77
112
|
helper: null,
|
|
78
113
|
declaredIn: null,
|
|
114
|
+
quiet: false,
|
|
115
|
+
registeredAt: null,
|
|
79
116
|
});
|
|
80
|
-
|
|
117
|
+
// Functions defined inside these are deferred in turn; the loop reaches them too, as they are added to the end.
|
|
118
|
+
for (const entry of this.deferred)
|
|
119
|
+
this.walkWhereWritten(entry);
|
|
120
|
+
// Records follow the order the file registers them in; the sort is stable, so one call's tests keep theirs.
|
|
121
|
+
this.tests.sort((a, b) => a.order - b.order);
|
|
122
|
+
this.suites.sort((a, b) => a.order - b.order);
|
|
123
|
+
const exported = this.exportedFunctions(this.main.parsed.program);
|
|
81
124
|
this.applyHelpers(exported);
|
|
82
125
|
this.checkFunctionTests(exported);
|
|
83
126
|
return {
|
|
84
127
|
suites: this.suites,
|
|
85
128
|
tests: this.tests,
|
|
86
|
-
problems: [...this.adapter.problems, ...this.problems],
|
|
87
|
-
usesGlobals: this.adapter.usesGlobals,
|
|
129
|
+
problems: [...this.main.adapter.problems, ...this.problems],
|
|
130
|
+
usesGlobals: this.main.adapter.usesGlobals,
|
|
88
131
|
};
|
|
89
132
|
}
|
|
90
133
|
visit(node, context) {
|
|
@@ -103,11 +146,15 @@ class Walker {
|
|
|
103
146
|
}
|
|
104
147
|
visitNode(node, context) {
|
|
105
148
|
switch (node.type) {
|
|
106
|
-
case 'CallExpression':
|
|
107
|
-
this.helperCall(node, context);
|
|
149
|
+
case 'CallExpression': {
|
|
150
|
+
const helper = this.helperCall(node, context);
|
|
108
151
|
if (this.call(node, context) || this.loopCall(node, context))
|
|
109
152
|
return;
|
|
110
|
-
|
|
153
|
+
this.visitChildren(node, context);
|
|
154
|
+
if (helper)
|
|
155
|
+
this.walkAtCall(helper, node, context);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
111
158
|
case 'IfStatement': {
|
|
112
159
|
// In-source tests sit inside `if (import.meta.vitest)`, which is how they are written, not a condition.
|
|
113
160
|
if (isImportMetaVitest(node.test))
|
|
@@ -133,6 +180,14 @@ class Walker {
|
|
|
133
180
|
this.visit(node.right, this.guarded(context, guard));
|
|
134
181
|
return;
|
|
135
182
|
}
|
|
183
|
+
case 'TryStatement':
|
|
184
|
+
this.visit(node.block, context);
|
|
185
|
+
// A `catch` block runs only when the `try` block throws, as when a test server can't start.
|
|
186
|
+
if (node.handler)
|
|
187
|
+
this.visit(node.handler, this.guarded(context, this.catchCondition(node.handler)));
|
|
188
|
+
if (node.finalizer)
|
|
189
|
+
this.visit(node.finalizer, context);
|
|
190
|
+
return;
|
|
136
191
|
case 'SwitchStatement': {
|
|
137
192
|
const discriminant = this.text(node.discriminant);
|
|
138
193
|
const values = node.cases.flatMap((switchCase) => (switchCase.test ? [this.text(switchCase.test)] : []));
|
|
@@ -158,14 +213,23 @@ class Walker {
|
|
|
158
213
|
// A function outside any test or hook runs when it is called; what it sets applies where it is called.
|
|
159
214
|
const helper = context.test || context.hookScope ? null : node;
|
|
160
215
|
const named = helper !== null && this.isNamed(node);
|
|
161
|
-
|
|
216
|
+
// In an imported helper's module, a function runs only when it is called, and is walked then.
|
|
217
|
+
if (named && this.frame !== this.main)
|
|
218
|
+
return;
|
|
219
|
+
if (named && !this.isDeferred.has(node)) {
|
|
220
|
+
// Its calls decide where its tests are registered, and a call may come after it, so it waits.
|
|
162
221
|
this.written.set(node, { ...this.placement(context), declaredIn: context.declaredIn });
|
|
222
|
+
this.isDeferred.add(node);
|
|
223
|
+
this.deferred.push({ fn: node, context, scopes: [...this.scopes], parents: [...this.parents] });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (named)
|
|
227
|
+
return;
|
|
163
228
|
this.visitChildren(node, {
|
|
164
229
|
...context,
|
|
165
230
|
isInFunction: true,
|
|
166
231
|
helper: helper ?? context.helper,
|
|
167
232
|
guards: helper ? [] : context.guards,
|
|
168
|
-
declaredIn: named ? node : context.declaredIn,
|
|
169
233
|
});
|
|
170
234
|
return;
|
|
171
235
|
}
|
|
@@ -187,6 +251,10 @@ class Walker {
|
|
|
187
251
|
inBody: context.test !== null || context.hookScope !== null,
|
|
188
252
|
};
|
|
189
253
|
}
|
|
254
|
+
/** A `catch` block's condition, written as its clause: `catch` or `catch (error)`. */
|
|
255
|
+
catchCondition(handler) {
|
|
256
|
+
return this.text({ start: handler.start, end: handler.body.start }).trim();
|
|
257
|
+
}
|
|
190
258
|
guarded(context, guard) {
|
|
191
259
|
return { ...context, isInCondition: true, guards: guard === null ? context.guards : [...context.guards, guard] };
|
|
192
260
|
}
|
|
@@ -202,7 +270,7 @@ class Walker {
|
|
|
202
270
|
this.scopes.pop();
|
|
203
271
|
}
|
|
204
272
|
text(node) {
|
|
205
|
-
return snippet(this.parsed.source.slice(node.start, node.end));
|
|
273
|
+
return snippet(this.frame.parsed.source.slice(node.start, node.end));
|
|
206
274
|
}
|
|
207
275
|
/** `symbols.forEach((symbol) => test(...))` declares its tests in a loop. */
|
|
208
276
|
loopCall(call, context) {
|
|
@@ -221,11 +289,12 @@ class Walker {
|
|
|
221
289
|
return true;
|
|
222
290
|
}
|
|
223
291
|
call(call, context) {
|
|
224
|
-
const api = this.adapter.classify(call, {
|
|
292
|
+
const api = this.frame.adapter.classify(call, {
|
|
225
293
|
scopes: this.scopes,
|
|
226
294
|
testContext: context.testContext,
|
|
227
295
|
inTest: context.test !== null,
|
|
228
296
|
inBody: context.test !== null || context.hookScope !== null,
|
|
297
|
+
returnsOf: (name, scopes) => this.returnsOf(name, scopes),
|
|
229
298
|
});
|
|
230
299
|
if (!api)
|
|
231
300
|
return false;
|
|
@@ -240,7 +309,11 @@ class Walker {
|
|
|
240
309
|
this.declareSuite(call, api, context);
|
|
241
310
|
return true;
|
|
242
311
|
case 'modifier': {
|
|
243
|
-
(context.test
|
|
312
|
+
if (context.test)
|
|
313
|
+
context.test.problems.push(...api.problems.map((item) => this.here(item)));
|
|
314
|
+
else
|
|
315
|
+
for (const item of api.problems)
|
|
316
|
+
this.report(item);
|
|
244
317
|
if (api.condition.applies === 'never')
|
|
245
318
|
return true;
|
|
246
319
|
const own = api.condition.applies === 'sometimes' ? [api.condition.text] : [];
|
|
@@ -249,7 +322,7 @@ class Walker {
|
|
|
249
322
|
condition: joinConditions([...context.guards, ...own]),
|
|
250
323
|
reason: api.reason,
|
|
251
324
|
hasDynamicReason: api.hasDynamicReason,
|
|
252
|
-
line: this.
|
|
325
|
+
line: this.lineOf(call),
|
|
253
326
|
};
|
|
254
327
|
if (context.helper)
|
|
255
328
|
this.effectsOf(context.helper).push({ kind: 'state', setting, call });
|
|
@@ -258,7 +331,8 @@ class Walker {
|
|
|
258
331
|
return true;
|
|
259
332
|
}
|
|
260
333
|
case 'configure':
|
|
261
|
-
|
|
334
|
+
for (const item of api.problems)
|
|
335
|
+
this.report(item);
|
|
262
336
|
if (context.helper) {
|
|
263
337
|
this.effectsOf(context.helper).push({ kind: 'configure', mode: api.mode, locks: api.locks, call });
|
|
264
338
|
}
|
|
@@ -281,11 +355,11 @@ class Walker {
|
|
|
281
355
|
return true;
|
|
282
356
|
case 'annotations':
|
|
283
357
|
context.test?.annotations.push(...api.items);
|
|
284
|
-
context.test?.problems.push(...api.problems);
|
|
358
|
+
context.test?.problems.push(...api.problems.map((item) => this.here(item)));
|
|
285
359
|
return true;
|
|
286
360
|
case 'invalid': {
|
|
287
361
|
const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
|
|
288
|
-
this.
|
|
362
|
+
this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
|
|
289
363
|
return true;
|
|
290
364
|
}
|
|
291
365
|
case 'other':
|
|
@@ -299,29 +373,259 @@ class Walker {
|
|
|
299
373
|
}
|
|
300
374
|
/** The function a name refers to when it is declared in the file, as a function or a variable holding one. */
|
|
301
375
|
functionNamed(name, scopes) {
|
|
302
|
-
|
|
376
|
+
return this.functionIn(this.frame, name, scopes);
|
|
377
|
+
}
|
|
378
|
+
functionIn(frame, name, scopes) {
|
|
379
|
+
const declaration = frame.values.declarationOf(name, scopes);
|
|
303
380
|
if (declaration?.type === 'FunctionDeclaration')
|
|
304
381
|
return declaration;
|
|
305
382
|
const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
|
|
306
383
|
return init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression' ? init : null;
|
|
307
384
|
}
|
|
308
|
-
/**
|
|
385
|
+
/**
|
|
386
|
+
* Records a call to a function declared in the file. A call from the file or a describe returns the function, to be
|
|
387
|
+
* walked there: its tests are registered in the describe around the call, and what it sets applies there. A call
|
|
388
|
+
* from a test, a hook or another function is applied later, through the states the function sets.
|
|
389
|
+
*/
|
|
309
390
|
helperCall(call, context) {
|
|
310
391
|
const callee = unwrap(call.callee);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
392
|
+
if (callee.type !== 'Identifier')
|
|
393
|
+
return null;
|
|
394
|
+
const fn = this.functionNamed(callee.name, this.scopes);
|
|
395
|
+
if (!fn) {
|
|
396
|
+
const runsHere = !context.test && !context.hookScope && !context.helper;
|
|
397
|
+
const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name, this.scopes) : null;
|
|
398
|
+
return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
|
|
399
|
+
}
|
|
400
|
+
const sites = this.callSites.get(fn) ?? new Map();
|
|
401
|
+
if (!sites.has(call))
|
|
402
|
+
sites.set(call, this.placement(context));
|
|
316
403
|
this.callSites.set(fn, sites);
|
|
317
404
|
if (context.helper) {
|
|
318
405
|
this.effectsOf(context.helper).push({ kind: 'call', fn, guards: context.guards });
|
|
319
406
|
}
|
|
320
|
-
else {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
407
|
+
else if (context.test || context.hookScope) {
|
|
408
|
+
this.helperCalls.push({
|
|
409
|
+
call,
|
|
410
|
+
fn,
|
|
411
|
+
target: context.test ?? context.hookScope,
|
|
412
|
+
guards: context.guards,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
else if (!this.callStack.includes(fn) && this.callStack.length < MAX_DEPTH) {
|
|
416
|
+
return this.withinLimit({ fn, frame: this.frame }, call);
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/** The function to walk at a call, while the file has calls left to walk; past MAX_CALLS, the call is reported once. */
|
|
421
|
+
withinLimit(callee, call) {
|
|
422
|
+
if (this.callsWalked < MAX_CALLS) {
|
|
423
|
+
this.callsWalked++;
|
|
424
|
+
return callee;
|
|
425
|
+
}
|
|
426
|
+
if (this.callsWalked === MAX_CALLS) {
|
|
427
|
+
this.callsWalked++;
|
|
428
|
+
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.`));
|
|
324
429
|
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* What a function declared in the file being walked, or imported from a local module, returns; null otherwise. Each
|
|
434
|
+
* declaration is looked up once, however many calls use it.
|
|
435
|
+
*/
|
|
436
|
+
returnsOf(name, scopes) {
|
|
437
|
+
const declaration = this.frame.values.declarationOf(name, scopes);
|
|
438
|
+
if (declaration === null)
|
|
439
|
+
return null;
|
|
440
|
+
if (!this.returns.has(declaration)) {
|
|
441
|
+
const local = this.functionNamed(name, scopes);
|
|
442
|
+
const callee = local ? { fn: local, frame: this.frame } : this.importedFunction(name, scopes);
|
|
443
|
+
this.returns.set(declaration, callee && this.returnsIn(callee));
|
|
444
|
+
}
|
|
445
|
+
return this.returns.get(declaration) ?? null;
|
|
446
|
+
}
|
|
447
|
+
returnsIn({ fn, frame }) {
|
|
448
|
+
// An async function returns a promise, and a generator an iterator, whatever their `return` says.
|
|
449
|
+
const values = fn.async || fn.generator ? null : returnValues(fn, [...this.scopeChain(fn, frame), fn]);
|
|
450
|
+
return { adapter: frame.adapter, values };
|
|
451
|
+
}
|
|
452
|
+
/** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
|
|
453
|
+
importedFunction(name, scopes) {
|
|
454
|
+
const declaration = this.frame.values.declarationOf(name, scopes);
|
|
455
|
+
const source = this.frame.values.importSource(name, scopes);
|
|
456
|
+
if (source === null)
|
|
457
|
+
return null;
|
|
458
|
+
const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
|
|
459
|
+
const frame = this.openFrom(this.frame, source);
|
|
460
|
+
return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
|
|
461
|
+
}
|
|
462
|
+
/** A module a file imports, read as a frame once; null when it can't be read or parsed. */
|
|
463
|
+
openFrom(frame, source) {
|
|
464
|
+
const module = frame.readModule?.(source);
|
|
465
|
+
if (!module || 'reason' in module)
|
|
466
|
+
return null;
|
|
467
|
+
if (!this.frames.has(module))
|
|
468
|
+
this.frames.set(module, this.openModule(module));
|
|
469
|
+
return this.frames.get(module) ?? null;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The function a module exports under a name, written there or re-exported from another module, as a barrel file
|
|
473
|
+
* does; null when the export isn't a function the scan can find. Each module is searched once for a name, however many
|
|
474
|
+
* barrels lead to it: one searched before didn't have it, or the search would have ended there.
|
|
475
|
+
*/
|
|
476
|
+
exportedFunction(frame, name, searched, depth) {
|
|
477
|
+
const key = `${frame.relativeFilePath}\0${name}`;
|
|
478
|
+
if (searched.has(key) || depth >= MAX_DEPTH)
|
|
479
|
+
return null;
|
|
480
|
+
searched.add(key);
|
|
481
|
+
const { program } = frame.parsed;
|
|
482
|
+
const local = (localName) => {
|
|
483
|
+
const fn = this.functionIn(frame, localName, [program]);
|
|
484
|
+
return fn ? { fn, frame } : null;
|
|
485
|
+
};
|
|
486
|
+
const from = (source, exported) => {
|
|
487
|
+
const module = this.openFrom(frame, source);
|
|
488
|
+
return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
|
|
489
|
+
};
|
|
490
|
+
for (const statement of program.body) {
|
|
491
|
+
if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
|
|
492
|
+
const declaration = statement.declaration;
|
|
493
|
+
if (declaration.type === 'FunctionDeclaration' || isFunctionNode(declaration))
|
|
494
|
+
return { fn: declaration, frame };
|
|
495
|
+
return declaration.type === 'Identifier' ? local(declaration.name) : null;
|
|
496
|
+
}
|
|
497
|
+
if (statement.type === 'ExportAllDeclaration' && !statement.exported) {
|
|
498
|
+
const found = from(statement.source.value, name);
|
|
499
|
+
if (found)
|
|
500
|
+
return found;
|
|
501
|
+
}
|
|
502
|
+
if (statement.type !== 'ExportNamedDeclaration')
|
|
503
|
+
continue;
|
|
504
|
+
const declaration = statement.declaration;
|
|
505
|
+
if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name === name)
|
|
506
|
+
return { fn: declaration, frame };
|
|
507
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
508
|
+
const declarator = declaration.declarations.find((item) => item.id.type === 'Identifier' && item.id.name === name);
|
|
509
|
+
if (declarator)
|
|
510
|
+
return local(name);
|
|
511
|
+
}
|
|
512
|
+
const specifier = statement.specifiers.find((item) => exportName(item.exported) === name);
|
|
513
|
+
if (specifier) {
|
|
514
|
+
const localName = exportName(specifier.local);
|
|
515
|
+
return statement.source ? from(statement.source.value, localName) : local(localName);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
|
|
522
|
+
* where it is written, and its parameters bound to the call's arguments so that titles built from them resolve. A
|
|
523
|
+
* function imported from another module is walked in that module, and what it registers points at this call.
|
|
524
|
+
*/
|
|
525
|
+
walkAtCall({ fn, frame }, call, context) {
|
|
526
|
+
this.walkedAtCall.add(fn);
|
|
527
|
+
// Arguments are worked out where the call is, in the file and scopes of the caller.
|
|
528
|
+
const spread = call.arguments.findIndex((item) => item.type === 'SpreadElement');
|
|
529
|
+
const args = (spread === -1 ? call.arguments : call.arguments.slice(0, spread)).map((item) => this.frame.values.resolve(item, this.scopes));
|
|
530
|
+
const caller = this.frame;
|
|
531
|
+
const site = this.site;
|
|
532
|
+
if (frame !== caller) {
|
|
533
|
+
this.frame = frame;
|
|
534
|
+
this.site ??= call;
|
|
535
|
+
}
|
|
536
|
+
const scopes = this.scopeChain(fn);
|
|
537
|
+
const saved = this.scopes.splice(0, this.scopes.length, ...scopes);
|
|
538
|
+
this.callStack.push(fn);
|
|
539
|
+
frame.values.withArguments(fn, args, scopes, () => {
|
|
540
|
+
this.visitCallback(fn, {
|
|
541
|
+
...context,
|
|
542
|
+
isInFunction: true,
|
|
543
|
+
declaredIn: null,
|
|
544
|
+
registeredAt: context.registeredAt ?? call.start,
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
this.callStack.pop();
|
|
548
|
+
this.scopes.splice(0, this.scopes.length, ...saved);
|
|
549
|
+
// What the module's own imports couldn't resolve is reported at the call, as it is why tests may be missing.
|
|
550
|
+
if (frame !== caller) {
|
|
551
|
+
for (const item of frame.adapter.problems.filter((found) => !this.moduleProblems.has(found))) {
|
|
552
|
+
this.moduleProblems.add(item);
|
|
553
|
+
this.report(item);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
this.frame = caller;
|
|
557
|
+
this.site = site;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Walks a deferred function where it is written. One that was walked at its calls only collects the states it sets,
|
|
561
|
+
* for calls from tests and hooks; one that wasn't records its tests where they are written, as nothing places them.
|
|
562
|
+
*/
|
|
563
|
+
walkWhereWritten({ fn, context, scopes, parents }) {
|
|
564
|
+
const savedScopes = this.scopes.splice(0, this.scopes.length, ...scopes);
|
|
565
|
+
const savedParents = this.parents.splice(0, this.parents.length, ...parents);
|
|
566
|
+
this.visitChildren(fn, {
|
|
567
|
+
...context,
|
|
568
|
+
isInFunction: true,
|
|
569
|
+
helper: fn,
|
|
570
|
+
guards: [],
|
|
571
|
+
declaredIn: fn,
|
|
572
|
+
quiet: this.walkedAtCall.has(fn),
|
|
573
|
+
});
|
|
574
|
+
this.scopes.splice(0, this.scopes.length, ...savedScopes);
|
|
575
|
+
this.parents.splice(0, this.parents.length, ...savedParents);
|
|
576
|
+
}
|
|
577
|
+
/** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
|
|
578
|
+
scopeChain(fn, frame = this.frame) {
|
|
579
|
+
const { program } = frame.parsed;
|
|
580
|
+
let chains = this.scopeChains.get(program);
|
|
581
|
+
if (!chains) {
|
|
582
|
+
const found = new Map();
|
|
583
|
+
const around = [];
|
|
584
|
+
const visit = (node) => {
|
|
585
|
+
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
586
|
+
found.set(node, [...around]);
|
|
587
|
+
const isScope = isScopeNode(node);
|
|
588
|
+
if (isScope)
|
|
589
|
+
around.push(node);
|
|
590
|
+
for (const child of childNodes(node))
|
|
591
|
+
visit(child);
|
|
592
|
+
if (isScope)
|
|
593
|
+
around.pop();
|
|
594
|
+
};
|
|
595
|
+
visit(program);
|
|
596
|
+
chains = found;
|
|
597
|
+
this.scopeChains.set(program, chains);
|
|
598
|
+
}
|
|
599
|
+
return chains.get(fn);
|
|
600
|
+
}
|
|
601
|
+
/** Reports a problem not tied to a test, once, however many times a function with it is walked. */
|
|
602
|
+
report(found) {
|
|
603
|
+
const item = this.here(found);
|
|
604
|
+
// Problems found in code point at it.
|
|
605
|
+
const key = `${item.code}\0${item.node.start}\0${item.message}`;
|
|
606
|
+
if (this.reported.has(key))
|
|
607
|
+
return;
|
|
608
|
+
this.reported.add(key);
|
|
609
|
+
this.problems.push(item);
|
|
610
|
+
}
|
|
611
|
+
/** A problem found in an imported helper points at the call in the scanned file that walks the helper. */
|
|
612
|
+
here(item) {
|
|
613
|
+
return this.site ? { ...item, node: this.site } : item;
|
|
614
|
+
}
|
|
615
|
+
/** The line in the scanned file of code being walked: its own, or that of the call walking an imported helper. */
|
|
616
|
+
lineOf(node) {
|
|
617
|
+
return this.main.parsed.source.line((this.site ?? node).start);
|
|
618
|
+
}
|
|
619
|
+
/** Where an imported helper writes a test or describe; null for code in the scanned file. */
|
|
620
|
+
declaredAt(call) {
|
|
621
|
+
if (!this.site)
|
|
622
|
+
return null;
|
|
623
|
+
return { relativeFilePath: this.frame.relativeFilePath, source: this.frame.parsed.source, start: call.start };
|
|
624
|
+
}
|
|
625
|
+
/** Leaves a test or describe out of the describe's body hash, when both are written in the same file. */
|
|
626
|
+
cut(scope, call) {
|
|
627
|
+
if (scope.suite && this.suiteFrames.get(scope.suite) === this.frame)
|
|
628
|
+
scope.suite.cuts.push(this.statement(call));
|
|
325
629
|
}
|
|
326
630
|
/** Functions the file exports, whose callers in other files are not followed. */
|
|
327
631
|
exportedFunctions(program) {
|
|
@@ -357,9 +661,18 @@ class Walker {
|
|
|
357
661
|
*/
|
|
358
662
|
applyHelpers(exported) {
|
|
359
663
|
const reached = new Set();
|
|
664
|
+
// For one call from a test or hook: the conditions each helper was expanded under, and how many expansions it took.
|
|
665
|
+
let expanded = new Map();
|
|
666
|
+
let steps = 0;
|
|
360
667
|
const expand = (fn, guards, path) => {
|
|
361
668
|
if (path.has(fn) || exported.has(fn))
|
|
362
669
|
return [];
|
|
670
|
+
// A helper reached again under the same conditions sets the same states again.
|
|
671
|
+
const conditions = expanded.get(fn) ?? new Set();
|
|
672
|
+
const key = guards.join('\0');
|
|
673
|
+
if (conditions.has(key) || ++steps > MAX_CALLS)
|
|
674
|
+
return [];
|
|
675
|
+
expanded.set(fn, conditions.add(key));
|
|
363
676
|
reached.add(fn);
|
|
364
677
|
return (this.effects.get(fn) ?? []).flatMap((effect) => {
|
|
365
678
|
if (effect.kind === 'call')
|
|
@@ -371,18 +684,23 @@ class Walker {
|
|
|
371
684
|
});
|
|
372
685
|
};
|
|
373
686
|
for (const call of this.helperCalls) {
|
|
687
|
+
expanded = new Map();
|
|
688
|
+
steps = 0;
|
|
374
689
|
for (const effect of expand(call.fn, call.guards, new Set())) {
|
|
690
|
+
// `describe.configure()` does nothing in a test or hook body.
|
|
375
691
|
if (effect.kind === 'state')
|
|
376
692
|
call.target.states.push(effect.setting);
|
|
377
|
-
|
|
378
|
-
|
|
693
|
+
}
|
|
694
|
+
if (steps > MAX_CALLS) {
|
|
695
|
+
const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
|
|
696
|
+
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));
|
|
379
697
|
}
|
|
380
698
|
}
|
|
381
699
|
for (const [fn, effects] of this.effects) {
|
|
382
700
|
const own = effects.find((effect) => effect.kind !== 'call');
|
|
383
|
-
if (!own || (reached.has(fn) && !exported.has(fn)))
|
|
701
|
+
if (!own || ((reached.has(fn) || this.walkedAtCall.has(fn)) && !exported.has(fn)))
|
|
384
702
|
continue;
|
|
385
|
-
const callee = snippet(this.parsed.source.slice(own.call.callee.start, own.call.callee.end));
|
|
703
|
+
const callee = snippet(this.frame.parsed.source.slice(own.call.callee.start, own.call.callee.end));
|
|
386
704
|
const why = exported.has(fn) ? 'is exported' : 'no test, hook or describe in the file calls';
|
|
387
705
|
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));
|
|
388
706
|
}
|
|
@@ -391,7 +709,7 @@ class Walker {
|
|
|
391
709
|
misplaced(fn, exported) {
|
|
392
710
|
if (exported.has(fn))
|
|
393
711
|
return 'the file exports the function';
|
|
394
|
-
const sites = this.callSites.get(fn) ?? [];
|
|
712
|
+
const sites = [...(this.callSites.get(fn)?.values() ?? [])];
|
|
395
713
|
if (sites.length === 0)
|
|
396
714
|
return 'nothing in the file calls the function by name';
|
|
397
715
|
if (sites.length > 1)
|
|
@@ -419,29 +737,32 @@ class Walker {
|
|
|
419
737
|
nested(call, context) {
|
|
420
738
|
if (!context.test && !context.hookScope)
|
|
421
739
|
return false;
|
|
422
|
-
this.
|
|
740
|
+
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))));
|
|
423
741
|
return true;
|
|
424
742
|
}
|
|
425
743
|
declareTest(call, declaration, context) {
|
|
426
|
-
if (this.nested(call, context))
|
|
744
|
+
if (context.quiet || this.nested(call, context))
|
|
427
745
|
return;
|
|
428
746
|
const statement = this.statement(call);
|
|
429
747
|
const test = {
|
|
430
748
|
scope: context.scope,
|
|
431
749
|
declaration,
|
|
432
750
|
call,
|
|
751
|
+
site: this.site,
|
|
752
|
+
declaredAt: this.declaredAt(call),
|
|
753
|
+
order: context.registeredAt ?? call.start,
|
|
433
754
|
states: [...declaration.states],
|
|
434
755
|
annotations: [...declaration.details.annotations],
|
|
435
756
|
isInLoop: context.isInLoop,
|
|
436
757
|
isInCondition: context.isInCondition,
|
|
437
758
|
isInFunction: context.isInFunction,
|
|
438
759
|
comments: this.commentsAbove(statement.start),
|
|
439
|
-
problems:
|
|
760
|
+
problems: declaration.problems.map((item) => this.here(item)),
|
|
440
761
|
};
|
|
441
762
|
this.tests.push(test);
|
|
442
763
|
if (context.declaredIn)
|
|
443
764
|
this.declaredIn.set(test, context.declaredIn);
|
|
444
|
-
context.scope
|
|
765
|
+
this.cut(context.scope, call);
|
|
445
766
|
if (declaration.fn) {
|
|
446
767
|
this.visitCallback(declaration.fn, {
|
|
447
768
|
...context,
|
|
@@ -454,9 +775,10 @@ class Walker {
|
|
|
454
775
|
}
|
|
455
776
|
}
|
|
456
777
|
declareSuite(call, declaration, context) {
|
|
457
|
-
if (this.nested(call, context))
|
|
778
|
+
if (context.quiet || this.nested(call, context))
|
|
458
779
|
return;
|
|
459
|
-
|
|
780
|
+
for (const item of declaration.problems)
|
|
781
|
+
this.report(item);
|
|
460
782
|
const scope = {
|
|
461
783
|
kind: 'describe',
|
|
462
784
|
parent: context.scope,
|
|
@@ -466,10 +788,19 @@ class Walker {
|
|
|
466
788
|
details: declaration.details,
|
|
467
789
|
suite: null,
|
|
468
790
|
};
|
|
469
|
-
const suite = {
|
|
791
|
+
const suite = {
|
|
792
|
+
scope,
|
|
793
|
+
declaration,
|
|
794
|
+
call,
|
|
795
|
+
site: this.site,
|
|
796
|
+
declaredAt: this.declaredAt(call),
|
|
797
|
+
order: context.registeredAt ?? call.start,
|
|
798
|
+
cuts: [],
|
|
799
|
+
};
|
|
470
800
|
scope.suite = suite;
|
|
471
801
|
this.suites.push(suite);
|
|
472
|
-
context.scope
|
|
802
|
+
this.cut(context.scope, call);
|
|
803
|
+
this.suiteFrames.set(suite, this.frame);
|
|
473
804
|
if (declaration.fn) {
|
|
474
805
|
this.visitCallback(declaration.fn, {
|
|
475
806
|
...context,
|
|
@@ -489,7 +820,7 @@ class Walker {
|
|
|
489
820
|
}
|
|
490
821
|
/** Comment lines on the lines directly above a statement, with no blank line in between. */
|
|
491
822
|
commentsAbove(start) {
|
|
492
|
-
const { comments, source } = this.parsed;
|
|
823
|
+
const { comments, source } = this.frame.parsed;
|
|
493
824
|
const lines = [];
|
|
494
825
|
let next = start;
|
|
495
826
|
// Comments are in source order; find the last one that ends before the statement.
|
|
@@ -512,6 +843,32 @@ class Walker {
|
|
|
512
843
|
return lines;
|
|
513
844
|
}
|
|
514
845
|
}
|
|
515
|
-
|
|
516
|
-
|
|
846
|
+
/**
|
|
847
|
+
* The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
|
|
848
|
+
* with `return;` or no `return` at all. A `return` in a function inside it is that function's.
|
|
849
|
+
*/
|
|
850
|
+
function returnValues(fn, scopes) {
|
|
851
|
+
// Only declarations of overloads have no body.
|
|
852
|
+
const body = fn.body;
|
|
853
|
+
if (body.type !== 'BlockStatement')
|
|
854
|
+
return [{ expression: body, scopes }];
|
|
855
|
+
const values = [];
|
|
856
|
+
const visit = (node, around) => {
|
|
857
|
+
if (node.type === 'ReturnStatement') {
|
|
858
|
+
values.push(node.argument ? { expression: node.argument, scopes: around } : null);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
862
|
+
return;
|
|
863
|
+
const inner = isScopeNode(node) ? [...around, node] : around;
|
|
864
|
+
for (const child of childNodes(node))
|
|
865
|
+
visit(child, inner);
|
|
866
|
+
};
|
|
867
|
+
visit(body, scopes);
|
|
868
|
+
const always = values.every((value) => value !== null);
|
|
869
|
+
return always && values.length > 0 ? values : null;
|
|
870
|
+
}
|
|
871
|
+
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
872
|
+
export function extract(main, openModule) {
|
|
873
|
+
return new Walker(main, openModule).run();
|
|
517
874
|
}
|