test-inventory 0.5.0 → 0.6.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 +26 -0
- package/dist/bin.js +1 -0
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +23 -2
- package/dist/config-dependencies.d.ts +62 -0
- package/dist/config-dependencies.js +231 -0
- package/dist/config-runs.d.ts +16 -0
- package/dist/config-runs.js +24 -1
- package/dist/dependency-hash.d.ts +242 -0
- package/dist/dependency-hash.js +1458 -0
- package/dist/diagnostics.js +1 -0
- package/dist/extract.d.ts +12 -0
- package/dist/extract.js +14 -4
- package/dist/filter.d.ts +6 -0
- package/dist/filter.js +11 -1
- package/dist/frameworks/common.d.ts +5 -0
- package/dist/frameworks/common.js +11 -0
- package/dist/frameworks/playwright.js +10 -2
- package/dist/frameworks/vitest.js +3 -1
- package/dist/hash.d.ts +1 -1
- package/dist/model.d.ts +3 -0
- package/dist/records.d.ts +4 -1
- package/dist/records.js +4 -0
- package/dist/scan-file.d.ts +8 -0
- package/dist/scan-file.js +11 -2
- package/dist/scan.js +73 -2
- package/dist/types.d.ts +11 -1
- package/package.json +1 -1
- package/schema/v1.json +21 -1
package/dist/diagnostics.js
CHANGED
|
@@ -26,6 +26,7 @@ export const LEVELS = {
|
|
|
26
26
|
'config-value-unknown': 'warning',
|
|
27
27
|
'file-not-run': 'warning',
|
|
28
28
|
'forbidden-only': 'error',
|
|
29
|
+
'dependency-not-followed': 'warning',
|
|
29
30
|
'symlink-ignored': 'warning',
|
|
30
31
|
'invalid-import': 'error',
|
|
31
32
|
'globals-not-enabled': 'warning',
|
package/dist/extract.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface Scope {
|
|
|
18
18
|
hookRun: RunSettings;
|
|
19
19
|
/** Fixtures of the hooks in the scope that run around each test. */
|
|
20
20
|
hookFixtures: string[];
|
|
21
|
+
/** Fixtures of every hook in the scope, `beforeAll` and `afterAll` too, which set up what its tests run. */
|
|
22
|
+
setupFixtures: string[];
|
|
23
|
+
/** A hook in the scope takes its fixtures whole, so it may use any. */
|
|
24
|
+
takesAllFixtures: boolean;
|
|
21
25
|
suite: PendingSuite | null;
|
|
22
26
|
}
|
|
23
27
|
/** Where a test or describe registered through an imported helper is written, in the helper's module. */
|
|
@@ -33,6 +37,10 @@ export interface PendingSuite {
|
|
|
33
37
|
/** For a describe declared in an imported helper, the call in the scanned file that registers it; null otherwise. */
|
|
34
38
|
site: Span | null;
|
|
35
39
|
declaredAt: DeclaredAt | null;
|
|
40
|
+
/** The file the describe is written in. */
|
|
41
|
+
frame: Frame;
|
|
42
|
+
/** The outermost helper call in the scanned file that declares the describe; null when the file declares it. */
|
|
43
|
+
registeredBy: CallExpression | null;
|
|
36
44
|
/** Where the scanned file registers the describe: its own call, or the outermost helper call that declares it. */
|
|
37
45
|
order: number;
|
|
38
46
|
/** Statements of the tests and describes directly inside, left out of the describe's body hash. */
|
|
@@ -48,6 +56,10 @@ export interface PendingTest {
|
|
|
48
56
|
/** For a test declared in an imported helper, the call in the scanned file that registers it; null otherwise. */
|
|
49
57
|
site: Span | null;
|
|
50
58
|
declaredAt: DeclaredAt | null;
|
|
59
|
+
/** The file the test is written in. */
|
|
60
|
+
frame: Frame;
|
|
61
|
+
/** The outermost helper call in the scanned file that declares the test; null when the file declares it. */
|
|
62
|
+
registeredBy: CallExpression | null;
|
|
51
63
|
/** Where the scanned file registers the test: its own call, or the outermost helper call that declares it. */
|
|
52
64
|
order: number;
|
|
53
65
|
/** States from the declaration, then from calls in the body such as `test.skip(condition)`. */
|
package/dist/extract.js
CHANGED
|
@@ -140,6 +140,8 @@ class Walker {
|
|
|
140
140
|
hooks: noHooks(),
|
|
141
141
|
hookRun: {},
|
|
142
142
|
hookFixtures: [],
|
|
143
|
+
setupFixtures: [],
|
|
144
|
+
takesAllFixtures: false,
|
|
143
145
|
suite: null,
|
|
144
146
|
};
|
|
145
147
|
}
|
|
@@ -158,7 +160,7 @@ class Walker {
|
|
|
158
160
|
helper: null,
|
|
159
161
|
declaredIn: null,
|
|
160
162
|
quiet: false,
|
|
161
|
-
|
|
163
|
+
registeredBy: null,
|
|
162
164
|
});
|
|
163
165
|
// Functions defined inside these are deferred in turn; the loop reaches them too, as they are added to the end.
|
|
164
166
|
for (const entry of this.deferred)
|
|
@@ -418,6 +420,8 @@ class Walker {
|
|
|
418
420
|
context.scope.hooks[api.name] += 1;
|
|
419
421
|
if (EACH_HOOKS.has(api.name))
|
|
420
422
|
context.scope.hookFixtures.push(...api.fixtures);
|
|
423
|
+
context.scope.setupFixtures.push(...api.fixtures);
|
|
424
|
+
context.scope.takesAllFixtures ||= api.takesAllFixtures;
|
|
421
425
|
}
|
|
422
426
|
if (api.fn) {
|
|
423
427
|
if (!call.arguments.includes(api.fn))
|
|
@@ -628,7 +632,7 @@ class Walker {
|
|
|
628
632
|
...context,
|
|
629
633
|
isInFunction: true,
|
|
630
634
|
declaredIn: null,
|
|
631
|
-
|
|
635
|
+
registeredBy: context.registeredBy ?? call,
|
|
632
636
|
});
|
|
633
637
|
});
|
|
634
638
|
this.callStack.pop();
|
|
@@ -926,7 +930,9 @@ class Walker {
|
|
|
926
930
|
call,
|
|
927
931
|
site: this.site,
|
|
928
932
|
declaredAt: this.declaredAt(call),
|
|
929
|
-
|
|
933
|
+
frame: this.frame,
|
|
934
|
+
registeredBy: context.registeredBy,
|
|
935
|
+
order: (context.registeredBy ?? call).start,
|
|
930
936
|
states: [...declaration.states],
|
|
931
937
|
annotations: [...declaration.details.annotations],
|
|
932
938
|
isInLoop: context.isInLoop,
|
|
@@ -976,6 +982,8 @@ class Walker {
|
|
|
976
982
|
hooks: noHooks(),
|
|
977
983
|
hookRun: {},
|
|
978
984
|
hookFixtures: [],
|
|
985
|
+
setupFixtures: [],
|
|
986
|
+
takesAllFixtures: false,
|
|
979
987
|
suite: null,
|
|
980
988
|
};
|
|
981
989
|
const suite = {
|
|
@@ -984,7 +992,9 @@ class Walker {
|
|
|
984
992
|
call,
|
|
985
993
|
site: this.site,
|
|
986
994
|
declaredAt: this.declaredAt(call),
|
|
987
|
-
|
|
995
|
+
frame: this.frame,
|
|
996
|
+
registeredBy: context.registeredBy,
|
|
997
|
+
order: (context.registeredBy ?? call).start,
|
|
988
998
|
cuts: [],
|
|
989
999
|
isInCondition: context.isInCondition,
|
|
990
1000
|
isInFunction: context.isInFunction,
|
package/dist/filter.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export interface InventoryFilter {
|
|
|
11
11
|
only?: boolean;
|
|
12
12
|
/** Keeps tests that don't run and give no reason. Tests in a file the runner refuses are left out. */
|
|
13
13
|
withoutReason?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Keeps the tests a change to one of these files can affect: those whose `dependencies` include it, and those that
|
|
16
|
+
* reach code the scan can't follow (`hasUnfollowedDependencies`). Paths are from the project root, as
|
|
17
|
+
* `dependencies` writes them. An empty list keeps only the tests the scan can't follow.
|
|
18
|
+
*/
|
|
19
|
+
related?: string[];
|
|
14
20
|
}
|
|
15
21
|
/** An inventory whose test records have only `id` and the keys picked with `pickTestFields()`. */
|
|
16
22
|
export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests'> & {
|
package/dist/filter.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { checkObject, OptionsError } from "./options.js";
|
|
2
3
|
import { summarize } from "./summary.js";
|
|
3
4
|
const STATES = ['active', 'skip', 'fixme', 'todo', 'expectedToFail', 'notLoaded'];
|
|
@@ -7,11 +8,15 @@ const FILTER_KEYS = [
|
|
|
7
8
|
'grep',
|
|
8
9
|
'only',
|
|
9
10
|
'withoutReason',
|
|
11
|
+
'related',
|
|
10
12
|
];
|
|
11
13
|
// Typed as a record of every key, so a key added to TestRecord fails to compile until it is listed here.
|
|
12
14
|
const TEST_KEYS = Object.keys({
|
|
13
15
|
id: true,
|
|
14
16
|
bodyHash: true,
|
|
17
|
+
dependencyHash: true,
|
|
18
|
+
dependencies: true,
|
|
19
|
+
hasUnfollowedDependencies: true,
|
|
15
20
|
suiteId: true,
|
|
16
21
|
suitePath: true,
|
|
17
22
|
relativeFilePath: true,
|
|
@@ -117,11 +122,16 @@ export function matcher(filter) {
|
|
|
117
122
|
const grep = pattern(checked.grep);
|
|
118
123
|
const only = flag(checked.only, 'filter.only');
|
|
119
124
|
const withoutReason = flag(checked.withoutReason, 'filter.withoutReason');
|
|
125
|
+
// Written as a shell or Windows may write them, as `./pages/order-ticket.ts` or `pages\\order-ticket.ts`.
|
|
126
|
+
const related = checked.related === undefined
|
|
127
|
+
? null
|
|
128
|
+
: new Set(list(checked.related, 'filter.related', null).map((file) => path.posix.normalize(file.replaceAll('\\', '/'))));
|
|
120
129
|
return (test) => (states.length === 0 || states.includes(test.state)) &&
|
|
121
130
|
(tags.length === 0 || test.tags.some((tag) => tags.includes(tag))) &&
|
|
122
131
|
(grep === null || grep.test(test.fullName)) &&
|
|
123
132
|
(!only || test.isOnly) &&
|
|
124
|
-
(!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null))
|
|
133
|
+
(!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null)) &&
|
|
134
|
+
(related === null || test.hasUnfollowedDependencies || test.dependencies.some((file) => related.has(file)));
|
|
125
135
|
}
|
|
126
136
|
/**
|
|
127
137
|
* Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
|
|
@@ -116,6 +116,11 @@ export declare function readOptions(file: FileContext, scopes: readonly Node[],
|
|
|
116
116
|
export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
|
|
117
117
|
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
118
118
|
export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
|
|
119
|
+
/**
|
|
120
|
+
* Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
|
|
121
|
+
* pattern, so that any key of it may be used.
|
|
122
|
+
*/
|
|
123
|
+
export declare function takesWhole(fn: FunctionNode | null, index: number | null): boolean;
|
|
119
124
|
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
120
125
|
export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
|
|
121
126
|
/** A warning for one option whose value could not be worked out. */
|
|
@@ -287,6 +287,17 @@ export function destructuredNames(fn, index) {
|
|
|
287
287
|
const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
|
|
288
288
|
return names.filter((name) => name !== null);
|
|
289
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
|
|
292
|
+
* pattern, so that any key of it may be used.
|
|
293
|
+
*/
|
|
294
|
+
export function takesWhole(fn, index) {
|
|
295
|
+
const parameter = index === null ? undefined : fn?.params[index];
|
|
296
|
+
if (!parameter)
|
|
297
|
+
return false;
|
|
298
|
+
return (parameter.type !== 'ObjectPattern' ||
|
|
299
|
+
parameter.properties.some((property) => property.type === 'RestElement' || property.computed));
|
|
300
|
+
}
|
|
290
301
|
/** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
|
|
291
302
|
export function readCount(file, name, option, problems) {
|
|
292
303
|
const { node, resolved } = option;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
3
|
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
|
|
4
|
-
import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
4
|
+
import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
5
5
|
const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
|
|
6
6
|
// Maps rather than object literals, so names such as `constructor` don't match inherited properties.
|
|
7
7
|
const DESCRIBES = new Map([
|
|
@@ -259,7 +259,14 @@ export class PlaywrightAdapter {
|
|
|
259
259
|
return this.configure(call, context);
|
|
260
260
|
if (HOOKS.has(key)) {
|
|
261
261
|
const fn = hookFunction(this.file, context.scopes, call.arguments);
|
|
262
|
-
return {
|
|
262
|
+
return {
|
|
263
|
+
kind: 'hook',
|
|
264
|
+
name: key,
|
|
265
|
+
fn,
|
|
266
|
+
testContext: null,
|
|
267
|
+
fixtures: destructuredNames(fn, 0),
|
|
268
|
+
takesAllFixtures: takesWhole(fn, 0),
|
|
269
|
+
};
|
|
263
270
|
}
|
|
264
271
|
if (RUN_MEMBERS.has(key))
|
|
265
272
|
return this.runSetting(call, key, context);
|
|
@@ -475,6 +482,7 @@ export class PlaywrightAdapter {
|
|
|
475
482
|
caseCount: null,
|
|
476
483
|
testContext: null,
|
|
477
484
|
fixtures: destructuredNames(hashed, 0),
|
|
485
|
+
takesAllFixtures: takesWhole(hashed, 0),
|
|
478
486
|
usesBench: false,
|
|
479
487
|
problems,
|
|
480
488
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
3
|
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
|
|
4
|
-
import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
|
|
4
|
+
import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
|
|
5
5
|
/** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
|
|
6
6
|
function shared(a, b) {
|
|
7
7
|
if (!a || a.kind !== b?.kind)
|
|
@@ -528,6 +528,7 @@ export class VitestAdapter {
|
|
|
528
528
|
fn,
|
|
529
529
|
testContext: index === null ? null : contextNames(fn, index),
|
|
530
530
|
fixtures: fixturesOf(fn, index, this.file.version),
|
|
531
|
+
takesAllFixtures: takesWhole(fn, index),
|
|
531
532
|
};
|
|
532
533
|
}
|
|
533
534
|
contextSkip(call, context) {
|
|
@@ -701,6 +702,7 @@ export class VitestAdapter {
|
|
|
701
702
|
...base,
|
|
702
703
|
testContext: contextNames(base.hashed, index),
|
|
703
704
|
fixtures: fixturesOf(base.hashed, index, this.file.version),
|
|
705
|
+
takesAllFixtures: takesWhole(base.hashed, index),
|
|
704
706
|
usesBench: atLeast(this.file.version, 5, 0) && destructuredNames(base.hashed, index).includes('bench'),
|
|
705
707
|
};
|
|
706
708
|
}
|
package/dist/hash.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Node } from 'oxc-parser';
|
|
2
2
|
import type { Span } from './model.ts';
|
|
3
|
-
export declare function shortHash(input: string): string;
|
|
3
|
+
export declare function shortHash(input: string | Uint8Array): string;
|
|
4
4
|
/**
|
|
5
5
|
* Hashes code by its syntax tree, so formatting, comments, quote style, trailing commas and line endings don't change
|
|
6
6
|
* the hash. Nodes at the `cut` spans, such as the tests inside a describe, are left out. Returns null when no code is
|
package/dist/model.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface TestDeclaration extends Declaration {
|
|
|
86
86
|
testContext: unknown;
|
|
87
87
|
/** Names the test function takes from its context, without the framework's own keys. */
|
|
88
88
|
fixtures: string[];
|
|
89
|
+
/** The test function takes its fixtures whole, or keeps the rest of them, so it may use any. */
|
|
90
|
+
takesAllFixtures: boolean;
|
|
89
91
|
/** Uses Vitest 5's `bench` fixture. */
|
|
90
92
|
usesBench: boolean;
|
|
91
93
|
}
|
|
@@ -128,6 +130,7 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
|
|
|
128
130
|
fn: FunctionNode | null;
|
|
129
131
|
testContext: unknown;
|
|
130
132
|
fixtures: string[];
|
|
133
|
+
takesAllFixtures: boolean;
|
|
131
134
|
}
|
|
132
135
|
/** Playwright `test.step()` or `test.step.skip()`. */
|
|
133
136
|
| {
|
package/dist/records.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type Extraction } from './extract.ts';
|
|
1
|
+
import { type Extraction, type PendingTest } from './extract.ts';
|
|
2
|
+
import type { Dependencies } from './dependency-hash.ts';
|
|
2
3
|
import type { RunSettings } from './model.ts';
|
|
3
4
|
import type { ParsedFile } from './parse.ts';
|
|
4
5
|
import type { Diagnostic, FileRecord, Framework, HookCounts, MetaValue, SuiteRecord, TestRecord } from './types.ts';
|
|
@@ -69,6 +70,8 @@ interface RecordOptions {
|
|
|
69
70
|
/** A Vitest 5 benchmark file, such as `*.bench.ts`. */
|
|
70
71
|
isBenchmarkFile: boolean;
|
|
71
72
|
config: FileConfig | null;
|
|
73
|
+
/** What a test runs, for its `dependencyHash`. */
|
|
74
|
+
dependencies: (test: PendingTest) => Dependencies;
|
|
72
75
|
}
|
|
73
76
|
/** The record of a scanned file, from its records and the hooks at its top level. */
|
|
74
77
|
export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
|
package/dist/records.js
CHANGED
|
@@ -334,6 +334,7 @@ class RecordBuilder {
|
|
|
334
334
|
}
|
|
335
335
|
test(pending, suites) {
|
|
336
336
|
const { declaration } = pending;
|
|
337
|
+
const dependencies = this.options.dependencies(pending);
|
|
337
338
|
// A test from an imported helper is placed at the call in this file that registers it.
|
|
338
339
|
const located = pending.site ?? pending.call;
|
|
339
340
|
const scopes = outward(pending.scope);
|
|
@@ -396,6 +397,9 @@ class RecordBuilder {
|
|
|
396
397
|
const record = {
|
|
397
398
|
id,
|
|
398
399
|
bodyHash: declaration.hashed ? hashNodes([declaration.hashed]) : null,
|
|
400
|
+
dependencyHash: dependencies.hash,
|
|
401
|
+
dependencies: dependencies.files,
|
|
402
|
+
hasUnfollowedDependencies: !dependencies.isComplete,
|
|
399
403
|
suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
|
|
400
404
|
suitePath,
|
|
401
405
|
relativeFilePath: this.options.relativeFilePath,
|
package/dist/scan-file.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { RunsWith } from './config-dependencies.ts';
|
|
2
|
+
import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
|
|
1
3
|
import type { ModuleReader } from './model.ts';
|
|
2
4
|
import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
|
|
3
5
|
import type { Framework } from './types.ts';
|
|
@@ -13,6 +15,12 @@ export interface FileSettings {
|
|
|
13
15
|
isBenchmarkFile?: boolean | null;
|
|
14
16
|
/** What the config the file uses sets for its tests; null without a config. */
|
|
15
17
|
config?: FileConfig | null;
|
|
18
|
+
/** Shared by the files of one scan, so that code many tests reach is read once. */
|
|
19
|
+
graph?: DependencyGraph;
|
|
20
|
+
/** What the file's config runs with each of its tests: the config, setup files and setup projects. */
|
|
21
|
+
runsWith?: RunsWith;
|
|
22
|
+
/** Reads the snapshot files tests compare against; none when one file's code is scanned. */
|
|
23
|
+
snapshotFiles?: SnapshotFiles;
|
|
16
24
|
}
|
|
17
25
|
export interface FileResult extends FileRecords {
|
|
18
26
|
usesGlobals: boolean;
|
package/dist/scan-file.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
|
|
1
2
|
import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
|
|
2
3
|
import { extract, noHooks } from "./extract.js";
|
|
3
4
|
import { parseModule } from "./frameworks/bindings.js";
|
|
@@ -39,20 +40,28 @@ export function scanFile(code, relativeFilePath, settings) {
|
|
|
39
40
|
const helper = parseModule(module);
|
|
40
41
|
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
|
|
41
42
|
});
|
|
43
|
+
const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
|
|
44
|
+
const unfollowed = [];
|
|
45
|
+
const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
|
|
42
46
|
const records = buildRecords(extraction, parsed, {
|
|
43
47
|
relativeFilePath,
|
|
44
48
|
framework: settings.framework,
|
|
45
49
|
comments: settings.comments,
|
|
46
|
-
isTypeTest
|
|
50
|
+
isTypeTest,
|
|
47
51
|
// Vitest 5's default `benchmark.include`, when no config says.
|
|
48
52
|
isBenchmarkFile: settings.framework === 'vitest' &&
|
|
49
53
|
atLeast(settings.version, 5, 0) &&
|
|
50
54
|
(settings.isBenchmarkFile ?? BENCHMARK.test(relativeFilePath)),
|
|
51
55
|
config: settings.config ?? null,
|
|
56
|
+
dependencies,
|
|
52
57
|
});
|
|
53
58
|
return {
|
|
54
59
|
...records,
|
|
55
|
-
diagnostics: [
|
|
60
|
+
diagnostics: [
|
|
61
|
+
...diagnostics,
|
|
62
|
+
...records.diagnostics,
|
|
63
|
+
...unfollowed.map((place) => toDiagnostic(place.problem, place.relativeFilePath, place.source)),
|
|
64
|
+
],
|
|
56
65
|
usesGlobals: extraction.usesGlobals,
|
|
57
66
|
};
|
|
58
67
|
}
|
package/dist/scan.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { glob } from 'tinyglobby';
|
|
5
|
+
import { ConfigDependencies } from "./config-dependencies.js";
|
|
4
6
|
import { ConfigRuns, fileConfig } from "./config-runs.js";
|
|
5
7
|
import { Configs } from "./config.js";
|
|
8
|
+
import { DependencyGraph } from "./dependency-hash.js";
|
|
6
9
|
import { fileDiagnostic } from "./diagnostics.js";
|
|
7
10
|
import { findFiles, normalizePattern, readFailure } from "./files.js";
|
|
8
11
|
import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
|
|
9
12
|
import { createInventory } from "./inventory.js";
|
|
10
|
-
import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
|
|
13
|
+
import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
11
14
|
import { moduleResolver } from "./resolve.js";
|
|
12
15
|
import { noRecords, scanFile } from "./scan-file.js";
|
|
13
16
|
import { globalsCheck } from "./vitest-config.js";
|
|
@@ -50,6 +53,43 @@ function folderHint(root, patterns, framework) {
|
|
|
50
53
|
}
|
|
51
54
|
/** The JavaScript and TypeScript files the runners read, which the configs choose the test files from. */
|
|
52
55
|
const SCRIPT_FILES = '**/*.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx,cjs,cjsx,cts,ctsx}';
|
|
56
|
+
/** Whether an error says a file or folder isn't there, as a snapshot isn't before a test first writes it. */
|
|
57
|
+
function isMissing(error) {
|
|
58
|
+
return error.code === 'ENOENT';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The files in a folder of the project and the folders in it, linked files too, as paths in the folder with `/`;
|
|
62
|
+
* none for no folder.
|
|
63
|
+
*/
|
|
64
|
+
function filesIn(root, folder) {
|
|
65
|
+
const base = path.join(root, folder);
|
|
66
|
+
try {
|
|
67
|
+
return readdirSync(base, { recursive: true, withFileTypes: true })
|
|
68
|
+
.filter((entry) => entry.isFile() || (entry.isSymbolicLink() && isFile(path.join(entry.parentPath, entry.name))))
|
|
69
|
+
.map((entry) => toPosixPath(path.relative(base, path.join(entry.parentPath, entry.name))))
|
|
70
|
+
.sort();
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return isMissing(error) ? [] : 'unreadable';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function isFile(file) {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(file).isFile();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** A file's bytes, from the project root; null when there is none. */
|
|
85
|
+
function bytesOf(root, file) {
|
|
86
|
+
try {
|
|
87
|
+
return readFileSync(path.join(root, file));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return isMissing(error) ? null : 'unreadable';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
53
93
|
/** Why a scan found no test files: what the patterns matched, or whether there is a config to find them from. */
|
|
54
94
|
function noFiles(root, patterns, framework, placements) {
|
|
55
95
|
if (patterns) {
|
|
@@ -94,10 +134,37 @@ export async function scan(options) {
|
|
|
94
134
|
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
95
135
|
projectDiagnostics.push(...found.diagnostics);
|
|
96
136
|
const readerFor = moduleResolver(root);
|
|
137
|
+
const graph = new DependencyGraph();
|
|
97
138
|
const configs = new Configs(root, framework, readerFor, rules);
|
|
98
139
|
const runs = new ConfigRuns(configs, framework, rules, patterns === null);
|
|
99
140
|
const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
|
|
100
141
|
const files = patterns ? found.files : found.files.filter((file) => placements.get(file.absolutePath)?.runs);
|
|
142
|
+
// A setup project's files may be outside the patterns, or ignored for the scan, so they are looked for in its testDir.
|
|
143
|
+
const setupDirs = new Set();
|
|
144
|
+
for (const { config, projects } of placements.values()) {
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const names = (projects ?? []).flatMap((project) => project.dependencies);
|
|
147
|
+
for (let name = names.pop(); config && name !== undefined; name = names.pop()) {
|
|
148
|
+
const project = seen.has(name) ? null : runs.projectNamed(config, name);
|
|
149
|
+
seen.add(name);
|
|
150
|
+
if (!project)
|
|
151
|
+
continue;
|
|
152
|
+
setupDirs.add(project.about.testDir);
|
|
153
|
+
names.push(...project.about.dependencies);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Searched from the folder itself, as a folder named like `(auth)` would be read as a glob in a pattern.
|
|
157
|
+
const setupFiles = await Promise.all([...setupDirs].map((folder) => glob(SCRIPT_FILES, { cwd: folder, absolute: true, ignore: ['**/node_modules/**'] })));
|
|
158
|
+
const candidates = setupFiles.flat().map((file) => path.resolve(file));
|
|
159
|
+
const configDependencies = new ConfigDependencies(framework, runs, readerFor, candidates);
|
|
160
|
+
// Many tests of a file compare against one folder, so each is listed once.
|
|
161
|
+
const listed = new Map();
|
|
162
|
+
const list = (folder) => {
|
|
163
|
+
if (!listed.has(folder))
|
|
164
|
+
listed.set(folder, filesIn(root, folder));
|
|
165
|
+
return listed.get(folder);
|
|
166
|
+
};
|
|
167
|
+
const snapshotFiles = { framework, list, read: (file) => bytesOf(root, file) };
|
|
101
168
|
const notRun = [];
|
|
102
169
|
const results = [];
|
|
103
170
|
const globalsFiles = [];
|
|
@@ -121,6 +188,7 @@ export async function scan(options) {
|
|
|
121
188
|
});
|
|
122
189
|
continue;
|
|
123
190
|
}
|
|
191
|
+
const runsWith = configDependencies.of(placement);
|
|
124
192
|
const result = scanFile(code, relativeFilePath, {
|
|
125
193
|
framework,
|
|
126
194
|
version: rules,
|
|
@@ -129,6 +197,9 @@ export async function scan(options) {
|
|
|
129
197
|
root,
|
|
130
198
|
isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
|
|
131
199
|
config: fileConfig(placement, framework, rules, absolutePath),
|
|
200
|
+
graph,
|
|
201
|
+
runsWith,
|
|
202
|
+
snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
|
|
132
203
|
});
|
|
133
204
|
if (result.usesGlobals)
|
|
134
205
|
globalsFiles.push({ absolutePath, relativeFilePath });
|
package/dist/types.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type SettingSource = 'test' | 'suite' | 'file' | 'config';
|
|
|
38
38
|
* @inline
|
|
39
39
|
*/
|
|
40
40
|
export type DiagnosticLevel = 'error' | 'warning';
|
|
41
|
-
export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'undefined-tag' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only';
|
|
41
|
+
export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'undefined-tag' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only' | 'dependency-not-followed';
|
|
42
42
|
export type MetaValue = string | number | boolean | null | MetaValue[] | {
|
|
43
43
|
[key: string]: MetaValue;
|
|
44
44
|
};
|
|
@@ -250,6 +250,16 @@ export interface TestRecord {
|
|
|
250
250
|
id: Hash;
|
|
251
251
|
/** Hash of the test body, ignoring whitespace and comments; null when the test has no body. */
|
|
252
252
|
bodyHash: Hash | null;
|
|
253
|
+
/**
|
|
254
|
+
* Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every
|
|
255
|
+
* describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores
|
|
256
|
+
* whitespace and comments. Installed packages are not in it.
|
|
257
|
+
*/
|
|
258
|
+
dependencyHash: Hash;
|
|
259
|
+
/** The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators. */
|
|
260
|
+
dependencies: string[];
|
|
261
|
+
/** Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`. */
|
|
262
|
+
hasUnfollowedDependencies: boolean;
|
|
253
263
|
/** The nearest describe; null outside any describe. */
|
|
254
264
|
suiteId: Hash | null;
|
|
255
265
|
/** Titles of the describes around the test, outermost first. */
|
package/package.json
CHANGED
package/schema/v1.json
CHANGED
|
@@ -754,6 +754,22 @@
|
|
|
754
754
|
],
|
|
755
755
|
"description": "Hash of the test body, ignoring whitespace and comments; null when the test has no body."
|
|
756
756
|
},
|
|
757
|
+
"dependencyHash": {
|
|
758
|
+
"type": "string",
|
|
759
|
+
"pattern": "^[0-9a-f]{16}$",
|
|
760
|
+
"description": "Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores whitespace and comments. Installed packages are not in it."
|
|
761
|
+
},
|
|
762
|
+
"dependencies": {
|
|
763
|
+
"type": "array",
|
|
764
|
+
"items": {
|
|
765
|
+
"type": "string"
|
|
766
|
+
},
|
|
767
|
+
"description": "The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators."
|
|
768
|
+
},
|
|
769
|
+
"hasUnfollowedDependencies": {
|
|
770
|
+
"type": "boolean",
|
|
771
|
+
"description": "Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`."
|
|
772
|
+
},
|
|
757
773
|
"suiteId": {
|
|
758
774
|
"anyOf": [
|
|
759
775
|
{
|
|
@@ -1160,6 +1176,9 @@
|
|
|
1160
1176
|
"required": [
|
|
1161
1177
|
"id",
|
|
1162
1178
|
"bodyHash",
|
|
1179
|
+
"dependencyHash",
|
|
1180
|
+
"dependencies",
|
|
1181
|
+
"hasUnfollowedDependencies",
|
|
1163
1182
|
"suiteId",
|
|
1164
1183
|
"suitePath",
|
|
1165
1184
|
"relativeFilePath",
|
|
@@ -1426,7 +1445,8 @@
|
|
|
1426
1445
|
"bench-outside-benchmark-file",
|
|
1427
1446
|
"config-value-unknown",
|
|
1428
1447
|
"file-not-run",
|
|
1429
|
-
"forbidden-only"
|
|
1448
|
+
"forbidden-only",
|
|
1449
|
+
"dependency-not-followed"
|
|
1430
1450
|
]
|
|
1431
1451
|
},
|
|
1432
1452
|
{
|