test-inventory 0.4.1 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +4 -1
  3. package/dist/ast.d.ts +3 -3
  4. package/dist/ast.js +4 -4
  5. package/dist/bin.js +1 -0
  6. package/dist/cli.d.ts +4 -2
  7. package/dist/cli.js +27 -7
  8. package/dist/config-dependencies.d.ts +62 -0
  9. package/dist/config-dependencies.js +231 -0
  10. package/dist/config-runs.d.ts +113 -0
  11. package/dist/config-runs.js +807 -0
  12. package/dist/config.d.ts +90 -0
  13. package/dist/config.js +652 -0
  14. package/dist/dependency-hash.d.ts +242 -0
  15. package/dist/dependency-hash.js +1458 -0
  16. package/dist/diagnostics.js +6 -1
  17. package/dist/extract.d.ts +20 -0
  18. package/dist/extract.js +20 -4
  19. package/dist/filter.d.ts +6 -0
  20. package/dist/filter.js +18 -1
  21. package/dist/frameworks/bindings.d.ts +22 -0
  22. package/dist/frameworks/bindings.js +19 -9
  23. package/dist/frameworks/common.d.ts +5 -0
  24. package/dist/frameworks/common.js +11 -0
  25. package/dist/frameworks/playwright.d.ts +2 -1
  26. package/dist/frameworks/playwright.js +11 -2
  27. package/dist/frameworks/vitest.d.ts +2 -1
  28. package/dist/frameworks/vitest.js +9 -3
  29. package/dist/hash.d.ts +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/inventory.d.ts +2 -2
  32. package/dist/inventory.js +2 -1
  33. package/dist/json.js +2 -0
  34. package/dist/model.d.ts +9 -1
  35. package/dist/options.d.ts +2 -2
  36. package/dist/records.d.ts +58 -2
  37. package/dist/records.js +196 -28
  38. package/dist/scan-file.d.ts +13 -1
  39. package/dist/scan-file.js +16 -4
  40. package/dist/scan-source.js +1 -1
  41. package/dist/scan.d.ts +2 -1
  42. package/dist/scan.js +131 -18
  43. package/dist/types.d.ts +83 -7
  44. package/dist/values.d.ts +5 -0
  45. package/dist/values.js +5 -0
  46. package/dist/vitest-config.d.ts +6 -4
  47. package/dist/vitest-config.js +32 -28
  48. package/dist/vitest-defaults.d.ts +6 -0
  49. package/dist/vitest-defaults.js +16 -0
  50. package/package.json +10 -8
  51. package/schema/v1.json +200 -6
package/dist/records.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { type Extraction } from './extract.ts';
1
+ import { type Extraction, type PendingTest } from './extract.ts';
2
+ import type { Dependencies } from './dependency-hash.ts';
3
+ import type { RunSettings } from './model.ts';
2
4
  import type { ParsedFile } from './parse.ts';
3
- import type { Diagnostic, FileRecord, Framework, HookCounts, SuiteRecord, TestRecord } from './types.ts';
5
+ import type { Diagnostic, FileRecord, Framework, HookCounts, MetaValue, SuiteRecord, TestRecord } from './types.ts';
4
6
  /** @inline */
5
7
  export type CommentsMode = 'all' | 'non-active' | 'none';
6
8
  export interface FileRecords {
@@ -9,6 +11,57 @@ export interface FileRecords {
9
11
  tests: TestRecord[];
10
12
  diagnostics: Diagnostic[];
11
13
  }
14
+ /** What the config the file uses sets for its tests, where the file sets nothing. */
15
+ export interface ConfigDefaults {
16
+ /** A run setting the config sets; null when its projects set different values or it depends on a condition. */
17
+ timeout?: number | null;
18
+ retry?: RunSettings['retry'] | null;
19
+ repeats?: number | null;
20
+ /** The source text of a run setting the config sets under a condition; null for any other. */
21
+ timeoutText: string | null;
22
+ retryText: string | null;
23
+ repeatsText: string | null;
24
+ /** Whether tests run in parallel unless the file says otherwise: Playwright's `fullyParallel`, Vitest's `sequence.concurrent`. */
25
+ parallel: boolean;
26
+ }
27
+ /** What a Vitest tag the config defines sets on the tests that carry it. */
28
+ export interface TagOptions {
29
+ skip?: boolean;
30
+ only?: boolean;
31
+ todo?: boolean;
32
+ fails?: boolean;
33
+ concurrent?: boolean;
34
+ run: RunSettings;
35
+ meta?: Record<string, MetaValue>;
36
+ }
37
+ export interface TagDefinition {
38
+ name: string;
39
+ /** Lower numbers win when two tags set the same option; a tag without one loses to every tag with one. */
40
+ priority: number | null;
41
+ options: TagOptions;
42
+ }
43
+ /** The tags the Vitest config defines (Vitest 4.1). */
44
+ export interface VitestTags {
45
+ definitions: TagDefinition[];
46
+ /** `strictTags`: a tag the config doesn't define fails the file while it loads. */
47
+ isStrict: boolean;
48
+ /** From Vitest 5 a tag's options win over the describe's around the test; before, the describe's win. */
49
+ beatsDescribes: boolean;
50
+ /** Vitest 4.1 passes the config's `sequence.concurrent`, when it sets one, to every test, over a tag's `concurrent`. */
51
+ concurrentBeatsTags: boolean;
52
+ }
53
+ /** What the config the file uses gives it. */
54
+ export interface FileConfig {
55
+ /** Whether a `.only` fails the run: Playwright's `forbidOnly`, always, never, or under the condition with this text. */
56
+ forbidOnly: boolean | string;
57
+ /** The names of the projects that run a test, null when unknown, and what they set for it. */
58
+ forTest(test: TestRecord, suites: SuiteRecord[]): {
59
+ projects: string[] | null;
60
+ defaults: ConfigDefaults | null;
61
+ };
62
+ /** Vitest's tags; null when the scan can't work them out, or the projects that run the file define different ones. */
63
+ tags: VitestTags | null;
64
+ }
12
65
  interface RecordOptions {
13
66
  relativeFilePath: string;
14
67
  framework: Framework;
@@ -16,6 +69,9 @@ interface RecordOptions {
16
69
  isTypeTest: boolean;
17
70
  /** A Vitest 5 benchmark file, such as `*.bench.ts`. */
18
71
  isBenchmarkFile: boolean;
72
+ config: FileConfig | null;
73
+ /** What a test runs, for its `dependencyHash`. */
74
+ dependencies: (test: PendingTest) => Dependencies;
19
75
  }
20
76
  /** The record of a scanned file, from its records and the hooks at its top level. */
21
77
  export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
package/dist/records.js CHANGED
@@ -2,6 +2,7 @@ import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
2
  import { anySlow, eitherCondition, noHooks, } from "./extract.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { hashNodes, shortHash } from "./hash.js";
5
+ import { snippet } from "./source.js";
5
6
  /**
6
7
  * The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo wins
7
8
  * over an unconditional expected failure, since the test doesn't run, and one set while the file loads wins over one
@@ -12,6 +13,65 @@ function chooseState(candidates) {
12
13
  const stops = always.filter((candidate) => candidate.setting.state !== 'expectedToFail');
13
14
  return (stops.find((candidate) => !candidate.setting.afterLoad) ?? stops.at(0) ?? always.at(0) ?? candidates.at(0) ?? null);
14
15
  }
16
+ /**
17
+ * The first use of each tag the Vitest config doesn't define, by `strictTags` an error that fails the file while it
18
+ * loads, in the order Vitest meets them: the `@module-tag` comments, the describes' `tags` as the describes are called,
19
+ * then the tests'. A use in a function or under a condition only fails the file if that code runs.
20
+ */
21
+ function undefinedTags(extraction, tags) {
22
+ const defined = new Set(tags.definitions.map(({ name }) => name));
23
+ const declared = (items) => items
24
+ .flatMap(({ declaration, site, call, isInCondition, isInFunction }) => declaration.details.tags.map((name) => ({ name, span: site ?? call, isSure: !isInCondition && !isInFunction })))
25
+ .sort((a, b) => a.span.start - b.span.start);
26
+ const uses = [
27
+ ...extraction.fileTags.map((tag) => ({ ...tag, isSure: true })),
28
+ ...declared(extraction.suites),
29
+ ...declared(extraction.tests),
30
+ ].filter(({ name }) => !defined.has(name));
31
+ // Each tag is reported once: at its first use that surely runs, else at its first use.
32
+ const first = new Map();
33
+ for (const use of uses) {
34
+ const known = first.get(use.name);
35
+ if (!known || (!known.isSure && use.isSure))
36
+ first.set(use.name, use);
37
+ }
38
+ const reported = [...first.values()].sort((a, b) => uses.indexOf(a) - uses.indexOf(b));
39
+ return reported.map(({ name, span, isSure }) => {
40
+ const consequence = isSure
41
+ ? 'Vitest fails the file while it loads, and none of its tests run'
42
+ : 'Vitest fails the file if this code runs while it loads';
43
+ const message = `The Vitest config doesn't define the tag "${name}", so with strictTags ${consequence}.`;
44
+ return { problem: problem('undefined-tag', span, message), isSure };
45
+ });
46
+ }
47
+ const FLAG_ORDER = ['skip', 'todo', 'expectedToFail'];
48
+ const ALWAYS = { condition: null, reason: null, hasDynamicReason: false };
49
+ /**
50
+ * The states of a test's declaration with those of its Vitest tags. Vitest merges the options and then takes `only`
51
+ * over `skip` over `todo`, so a `.only` on either side drops the skip and todo of both.
52
+ */
53
+ function declaredStates(declared, options, { isOnly, skipPassedDown }) {
54
+ const own = declared.map((setting) => ({ setting, source: 'test' }));
55
+ if (options === null)
56
+ return own;
57
+ // A describe's `skip` option is merged in too, where the scan otherwise leaves it to the describe's own state; the
58
+ // test's own skip, when it has one, sorts first.
59
+ const passed = skipPassedDown === undefined
60
+ ? []
61
+ : [{ setting: { ...ALWAYS, state: 'skip', line: skipPassedDown }, source: 'suite' }];
62
+ const all = [...own, ...passed, ...tagStates(options).map((setting) => ({ setting, source: 'config' }))];
63
+ const kept = isOnly || options.only ? all.filter(({ setting }) => setting.state === 'expectedToFail') : all;
64
+ return kept.sort((a, b) => FLAG_ORDER.indexOf(a.setting.state) - FLAG_ORDER.indexOf(b.setting.state));
65
+ }
66
+ /** The states the options of a test's Vitest tags set. */
67
+ function tagStates(options) {
68
+ const states = [
69
+ [options.skip, 'skip'],
70
+ [options.todo, 'todo'],
71
+ [options.fails, 'expectedToFail'],
72
+ ];
73
+ return states.filter(([isSet]) => isSet === true).map(([, state]) => ({ ...ALWAYS, state, line: null }));
74
+ }
15
75
  /** The scope and the scopes around it, innermost first. */
16
76
  function outward(scope) {
17
77
  const scopes = [];
@@ -30,6 +90,10 @@ function bodyNodes(fn) {
30
90
  const body = fn.body;
31
91
  return body.type === 'BlockStatement' ? body.body : [body];
32
92
  }
93
+ /** Where a run setting of a scope is written, for a test in it: the file, or a describe. */
94
+ function placeOf(scope) {
95
+ return scope.kind === 'file' ? 'file' : 'suite';
96
+ }
33
97
  function addHooks(scopes) {
34
98
  const total = noHooks();
35
99
  for (const scope of scopes) {
@@ -56,11 +120,47 @@ function runKeys(places) {
56
120
  repeats: first('repeats') ?? null,
57
121
  };
58
122
  }
123
+ /** A test's run settings from the file, and where each comes from: the nearest place that sets it. */
124
+ function testRun(places) {
125
+ const from = (key) => places.find((place) => place.run[key] !== undefined)?.source ?? null;
126
+ return {
127
+ ...runKeys(places.map((place) => place.run)),
128
+ timeoutSource: from('timeout'),
129
+ retriesSource: from('retry'),
130
+ repeatsSource: from('repeats'),
131
+ timeoutConfigText: null,
132
+ retriesConfigText: null,
133
+ repeatsConfigText: null,
134
+ };
135
+ }
136
+ /** A test's run settings, with what the config sets where the file sets nothing. */
137
+ function withConfig(fromFile, config) {
138
+ const run = { ...fromFile };
139
+ if (run.timeoutSource === null && config?.timeout !== undefined) {
140
+ run.timeout = config.timeout;
141
+ run.timeoutSource = 'config';
142
+ run.timeoutConfigText = config.timeoutText;
143
+ }
144
+ if (run.retriesSource === null && config?.retry !== undefined) {
145
+ run.retries = config.retry?.count ?? null;
146
+ run.retryDelay = config.retry?.delay ?? null;
147
+ run.retryCondition = config.retry?.condition ?? null;
148
+ run.retriesSource = 'config';
149
+ run.retriesConfigText = config.retryText;
150
+ }
151
+ if (run.repeatsSource === null && config?.repeats !== undefined) {
152
+ run.repeats = config.repeats;
153
+ run.repeatsSource = 'config';
154
+ run.repeatsConfigText = config.repeatsText;
155
+ }
156
+ return run;
157
+ }
59
158
  /** The record of a scanned file, from its records and the hooks at its top level. */
60
159
  export function fileRecord(relativeFilePath, hooks, records) {
61
160
  return {
62
161
  relativeFilePath,
63
162
  fileName: relativeFilePath.slice(relativeFilePath.lastIndexOf('/') + 1),
163
+ config: null,
64
164
  hooks,
65
165
  suiteCount: records.suites.length,
66
166
  testCount: records.tests.length,
@@ -94,19 +194,28 @@ class RecordBuilder {
94
194
  }
95
195
  build(extraction) {
96
196
  const duplicates = this.duplicates(extraction.tests);
97
- const rejecting = [
98
- ...extraction.problems,
99
- ...extraction.tests.flatMap((test) => test.problems),
100
- ...duplicates.map((item) => item.problem),
101
- ].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) && item.afterLoad !== true);
102
- // Each of these problems points at the code that causes it.
103
- const lines = rejecting.map((item) => this.parsed.source.line(item.node.start));
197
+ const tags = this.options.framework === 'vitest' ? (this.options.config?.tags ?? null) : null;
198
+ const testProblems = extraction.tests.flatMap((test) => test.problems);
199
+ // With the config's tags known, what they do is in the records, unless a tag can't be worked out.
200
+ const isTagMissing = [...extraction.problems, ...testProblems].some((item) => item.code === 'unresolved-tag');
201
+ const undefinedUses = tags?.isStrict ? undefinedTags(extraction, tags) : [];
202
+ const problems = [
203
+ ...extraction.problems.filter((item) => tags === null || isTagMissing || item.code !== 'config-may-change-state'),
204
+ ...undefinedUses.map((use) => use.problem),
205
+ ];
206
+ const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) && item.code !== 'undefined-tag' && item.afterLoad !== true);
207
+ // Each of these problems points at the code that causes it. Vitest meets the tags in the order they are listed.
208
+ const firstTag = undefinedUses.find((use) => use.isSure)?.problem;
209
+ const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
104
210
  if (lines.length > 0)
105
211
  this.rejectedAt = Math.min(...lines);
106
212
  const suites = extraction.suites.map((suite) => this.suite(suite));
107
- const tests = extraction.tests.map((test) => this.test(test));
108
- for (const item of extraction.problems)
109
- this.diagnostics.push(this.diagnostic(item));
213
+ const tests = extraction.tests.map((test) => this.test(test, suites));
214
+ for (const item of problems) {
215
+ const found = this.diagnostic(item);
216
+ const isSure = undefinedUses.find((use) => use.problem === item)?.isSure ?? true;
217
+ this.diagnostics.push(isSure ? found : { ...found, level: 'warning' });
218
+ }
110
219
  for (const { index, problem: item } of duplicates) {
111
220
  this.diagnostics.push(this.diagnostic(item, tests[index].id));
112
221
  }
@@ -144,8 +253,25 @@ class RecordBuilder {
144
253
  this.ordinals.set(key, ordinal + 1);
145
254
  return shortHash(JSON.stringify([this.options.relativeFilePath, ...parts, ordinal]));
146
255
  }
256
+ /**
257
+ * Reports a `.only` that the config's `forbidOnly` makes fail the run, at `located`; one under a condition is a
258
+ * warning. `call` is the `.only` call when it is in this file, whose callee the diagnostic shows.
259
+ */
260
+ forbidOnly(located, call) {
261
+ const forbid = this.options.config?.forbidOnly ?? false;
262
+ if (forbid === false)
263
+ return;
264
+ const message = forbid === true
265
+ ? 'The config sets forbidOnly, so this .only fails the run before any test runs.'
266
+ : `The config sets forbidOnly when ${forbid}, so then this .only fails the run before any test runs.`;
267
+ const callee = call && snippet(this.parsed.source.text.slice(call.callee.start, call.callee.end));
268
+ const found = problem('forbidden-only', located, message, callee);
269
+ this.diagnostics.push(this.diagnostic(forbid === true ? found : { ...found, level: 'warning' }));
270
+ }
147
271
  suite(pending) {
148
272
  const { scope, declaration, call } = pending;
273
+ if (declaration.isOnly)
274
+ this.forbidOnly(pending.site ?? call, pending.site ? null : call);
149
275
  const parents = outward(scope).slice(1, -1).reverse();
150
276
  const title = declaration.title;
151
277
  // Suite ids are marked so that a describe and a test with the same title in the same place differ.
@@ -187,16 +313,47 @@ class RecordBuilder {
187
313
  ...runKeys([scope.details.run]),
188
314
  };
189
315
  }
190
- test(pending) {
316
+ /** What the config's Vitest tags set on a test with `tags`, merged as Vitest merges them; null when none applies. */
317
+ tagOptions(tags) {
318
+ const definitions = this.options.config?.tags?.definitions ?? [];
319
+ const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
320
+ if (applied.length === 0)
321
+ return null;
322
+ // Vitest applies the tags from the highest number to the lowest, so the lowest wins; a tag without one goes first.
323
+ const rank = ({ priority }) => priority ?? Infinity;
324
+ applied.sort((a, b) => rank(b) - rank(a));
325
+ const merged = { run: {} };
326
+ for (const { options } of applied) {
327
+ const { run, meta, ...flags } = options;
328
+ Object.assign(merged, flags);
329
+ Object.assign(merged.run, run);
330
+ if (meta)
331
+ merged.meta = { ...merged.meta, ...meta };
332
+ }
333
+ return merged;
334
+ }
335
+ test(pending, suites) {
191
336
  const { declaration } = pending;
337
+ const dependencies = this.options.dependencies(pending);
192
338
  // A test from an imported helper is placed at the call in this file that registers it.
193
339
  const located = pending.site ?? pending.call;
194
340
  const scopes = outward(pending.scope);
195
341
  const describes = scopes.filter((scope) => scope.kind === 'describe').reverse();
196
342
  const title = declaration.title;
197
343
  const id = this.id([this.describeTitles(describes), title.withoutTags]);
344
+ const fileScope = scopes[scopes.length - 1];
345
+ const tags = unique([
346
+ ...fileScope.details.tags,
347
+ ...describes.flatMap((scope) => scope.details.tags),
348
+ ...declaration.details.tags,
349
+ ]);
350
+ const fromTags = this.tagOptions(tags);
351
+ // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
352
+ const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
353
+ const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
198
354
  const chosen = chooseState([
199
- ...pending.states.map((setting) => ({ setting, source: 'test' })),
355
+ ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
356
+ ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
200
357
  ...scopes.flatMap((scope) => scope.states.map((setting) => ({
201
358
  setting,
202
359
  source: scope.kind === 'file' ? 'file' : 'suite',
@@ -207,13 +364,14 @@ class RecordBuilder {
207
364
  const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
208
365
  const setting = rejected ? null : (chosen?.setting ?? null);
209
366
  const suitePath = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []);
210
- const fileScope = scopes[scopes.length - 1];
211
- const mode = declaration.mode ?? scopes.find((scope) => scope.mode !== null)?.mode ?? null;
367
+ const tagConcurrent = this.options.config?.tags?.concurrentBeatsTags ? undefined : fromTags?.concurrent;
368
+ const tagMode = tagConcurrent === undefined ? null : tagConcurrent ? 'parallel' : 'default';
369
+ const [mode = null] = tagged([declaration.mode], [tagMode], scopes.map((scope) => scope.mode)).filter((item) => item !== null);
212
370
  const meta = {};
213
- for (const values of [...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
371
+ for (const values of [fromTags?.meta, ...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
214
372
  Object.assign(meta, values);
215
373
  }
216
- const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly);
374
+ const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
217
375
  const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
218
376
  ? pending.comments
219
377
  : [];
@@ -225,15 +383,23 @@ class RecordBuilder {
225
383
  const message = 'The test uses the bench fixture, which Vitest only provides in benchmark files such as *.bench.ts; elsewhere the test fails.';
226
384
  this.diagnostics.push(this.diagnostic(problem('bench-outside-benchmark-file', located, message), id));
227
385
  }
386
+ if (declaration.isOnly)
387
+ this.forbidOnly(located, pending.site ? null : pending.call);
228
388
  if (pending.isInLoop) {
229
389
  this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
230
390
  }
231
- if (declaration.isOnly && chosen && chosen.source !== 'test' && chosen.setting.state === 'skip') {
391
+ if ((declaration.isOnly || fromTags?.only === true) &&
392
+ chosen &&
393
+ chosen.source !== 'test' &&
394
+ chosen.setting.state === 'skip') {
232
395
  this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe', located, 'The test uses .only, but a describe around it is skipped, so it is skipped too.'), id));
233
396
  }
234
- return {
397
+ const record = {
235
398
  id,
236
399
  bodyHash: declaration.hashed ? hashNodes([declaration.hashed]) : null,
400
+ dependencyHash: dependencies.hash,
401
+ dependencies: dependencies.files,
402
+ hasUnfollowedDependencies: !dependencies.isComplete,
237
403
  suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
238
404
  suitePath,
239
405
  relativeFilePath: this.options.relativeFilePath,
@@ -261,11 +427,7 @@ class RecordBuilder {
261
427
  stateLine: rejected ? this.rejectedAt : (setting?.line ?? null),
262
428
  isParallel: mode === 'parallel',
263
429
  isSerial: mode === 'serial',
264
- tags: unique([
265
- ...fileScope.details.tags,
266
- ...describes.flatMap((scope) => scope.details.tags),
267
- ...declaration.details.tags,
268
- ]),
430
+ tags,
269
431
  annotations: [...describes.flatMap((scope) => scope.details.annotations), ...pending.annotations],
270
432
  comments,
271
433
  locks: unique([
@@ -276,11 +438,10 @@ class RecordBuilder {
276
438
  meta: Object.keys(meta).length > 0 ? meta : null,
277
439
  hooks: addHooks(scopes),
278
440
  // Calls in the body and in `beforeEach` hooks run last, so they win over what the describes set.
279
- ...runKeys([
280
- pending.run,
281
- ...scopes.map((scope) => scope.hookRun),
282
- declaration.details.run,
283
- ...scopes.map((scope) => scope.details.run),
441
+ ...testRun([
442
+ { run: pending.run, source: 'test' },
443
+ ...scopes.map((scope) => ({ run: scope.hookRun, source: placeOf(scope) })),
444
+ ...tagged([{ run: declaration.details.run, source: 'test' }], [{ run: fromTags?.run ?? {}, source: 'config' }], scopes.map((scope) => ({ run: scope.details.run, source: placeOf(scope) }))),
284
445
  ]),
285
446
  fixtures: unique([...declaration.fixtures, ...[...scopes].reverse().flatMap((scope) => scope.hookFixtures)]),
286
447
  expectCount: pending.expectCount,
@@ -294,6 +455,13 @@ class RecordBuilder {
294
455
  isInLoop: pending.isInLoop,
295
456
  isInCondition: pending.isInCondition,
296
457
  isInFunction: pending.isInFunction,
458
+ projects: null,
459
+ };
460
+ const { projects, defaults } = this.options.config?.forTest(record, suites) ?? { projects: null, defaults: null };
461
+ return {
462
+ ...withConfig(record, defaults),
463
+ isParallel: record.isParallel || (mode === null && defaults?.parallel === true),
464
+ projects,
297
465
  };
298
466
  }
299
467
  /**
@@ -1,5 +1,7 @@
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
- import { type CommentsMode, type FileRecords } from './records.ts';
4
+ import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
3
5
  import type { Framework } from './types.ts';
4
6
  export interface FileSettings {
5
7
  framework: Framework;
@@ -9,6 +11,16 @@ export interface FileSettings {
9
11
  readModule: ModuleReader | null;
10
12
  /** The project root, left out of error messages so that the output has no absolute paths. */
11
13
  root: string | null;
14
+ /** Whether the config runs the file as a benchmark; null takes it from the file's name, as Vitest 5's default does. */
15
+ isBenchmarkFile?: boolean | null;
16
+ /** What the config the file uses sets for its tests; null without a config. */
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;
12
24
  }
13
25
  export interface FileResult extends FileRecords {
14
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,17 +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: settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath),
47
- // Vitest 5's default `benchmark.include`.
48
- isBenchmarkFile: settings.framework === 'vitest' && atLeast(settings.version, 5, 0) && BENCHMARK.test(relativeFilePath),
50
+ isTypeTest,
51
+ // Vitest 5's default `benchmark.include`, when no config says.
52
+ isBenchmarkFile: settings.framework === 'vitest' &&
53
+ atLeast(settings.version, 5, 0) &&
54
+ (settings.isBenchmarkFile ?? BENCHMARK.test(relativeFilePath)),
55
+ config: settings.config ?? null,
56
+ dependencies,
49
57
  });
50
58
  return {
51
59
  ...records,
52
- diagnostics: [...diagnostics, ...records.diagnostics],
60
+ diagnostics: [
61
+ ...diagnostics,
62
+ ...records.diagnostics,
63
+ ...unfollowed.map((place) => toDiagnostic(place.problem, place.relativeFilePath, place.source)),
64
+ ],
53
65
  usesGlobals: extraction.usesGlobals,
54
66
  };
55
67
  }
@@ -22,5 +22,5 @@ export function scanSource(options) {
22
22
  readModule: null,
23
23
  root: null,
24
24
  });
25
- return createInventory(framework, frameworkVersion, 1, [result], []);
25
+ return createInventory(framework, frameworkVersion, 1, [result], [], []);
26
26
  }
package/dist/scan.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type ScanOptions } from './options.ts';
2
2
  import type { Inventory } from './types.ts';
3
3
  /**
4
- * Scans the test files that match `patterns` under `root`, one file at a time.
4
+ * Scans the test files that match `patterns` under `root`, or without patterns those the configs run, one file at a
5
+ * time.
5
6
  *
6
7
  * Throws a `TypeError` for invalid options. Problems with files and code are reported as diagnostics.
7
8
  */