test-inventory 0.3.0 → 0.4.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.
@@ -62,6 +62,11 @@ export declare class VitestAdapter implements Adapter {
62
62
  private hookCall;
63
63
  private hook;
64
64
  private contextSkip;
65
+ /**
66
+ * `annotate(message, type?)` from the test context (3.2), as an annotation of type `notice` when no type is given; the
67
+ * second argument may also be an attachment.
68
+ */
69
+ private annotate;
65
70
  private declaration;
66
71
  /** Whether an argument holds options: an object, a name for one, or anything but a function before the body. */
67
72
  private isOptions;
@@ -69,6 +74,8 @@ export declare class VitestAdapter implements Adapter {
69
74
  /** Vitest's order: `only` wins over `skip`, which wins over `todo`. */
70
75
  private states;
71
76
  private readDeclarationOptions;
77
+ /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`; the runner ignores an object before. */
78
+ private retry;
72
79
  private meta;
73
80
  private caseCount;
74
81
  private arrayLength;
@@ -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, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
4
+ import { atLeast, findFunction, 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";
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)
@@ -37,6 +37,9 @@ const HOOKS = new Map([
37
37
  ['afterAll', null],
38
38
  ['aroundAll', null],
39
39
  ]);
40
+ function isHook(name) {
41
+ return HOOKS.has(name);
42
+ }
40
43
  /** First members of chains that never declare a test, even when Vitest refuses the rest of the chain. */
41
44
  const NOT_DECLARING = new Set([...HOOKS.keys(), ...DERIVATIONS]);
42
45
  /** First members that show a name imported from a local file is used as Vitest's `test`. */
@@ -91,6 +94,23 @@ function hoistedValue(call, parents) {
91
94
  const parent = parents.at(-1);
92
95
  return parent?.type === 'AwaitExpression' ? parent : call;
93
96
  }
97
+ /** Keys of the test context that Vitest provides itself, with the version that added each; they are not fixtures. */
98
+ const CONTEXT_KEYS = new Map([
99
+ ['task', [3, 0]],
100
+ ['expect', [3, 0]],
101
+ ['skip', [3, 0]],
102
+ ['onTestFailed', [3, 0]],
103
+ ['onTestFinished', [3, 0]],
104
+ ['annotate', [3, 2]],
105
+ ['signal', [3, 2]],
106
+ ['bench', [5, 0]],
107
+ ]);
108
+ function fixturesOf(fn, index, version) {
109
+ return destructuredNames(fn, index).filter((name) => {
110
+ const since = CONTEXT_KEYS.get(name);
111
+ return !since || !atLeast(version, ...since);
112
+ });
113
+ }
94
114
  /** The names a file binds to Vitest's `test`, `describe`, hooks and namespace. */
95
115
  class VitestBindings {
96
116
  kinds = new Map();
@@ -202,7 +222,7 @@ class VitestBindings {
202
222
  if (kind) {
203
223
  this.kinds.set(local, kind);
204
224
  }
205
- else if (HOOKS.has(imported)) {
225
+ else if (isHook(imported)) {
206
226
  this.hooks.set(local, imported);
207
227
  }
208
228
  else if (imported === 'bench' && atLeast(version, 5, 0)) {
@@ -227,7 +247,7 @@ class VitestBindings {
227
247
  const kind = KINDS.get(key ?? '');
228
248
  if (kind)
229
249
  this.kinds.set(property.value.name, kind);
230
- if (key !== null && HOOKS.has(key))
250
+ if (key !== null && isHook(key))
231
251
  this.hooks.set(property.value.name, key);
232
252
  }
233
253
  return true;
@@ -307,7 +327,7 @@ export class VitestAdapter {
307
327
  if (!resolved)
308
328
  return null;
309
329
  const { kind, links } = resolved;
310
- if (kind === 'test' && links.length === 1 && HOOKS.has(links[0].name))
330
+ if (kind === 'test' && links.length === 1 && isHook(links[0].name))
311
331
  return this.hook(call, links[0].name);
312
332
  if (links.length === 1 && DERIVATIONS.has(links[0].name))
313
333
  return { kind: 'other' };
@@ -492,27 +512,28 @@ export class VitestAdapter {
492
512
  return null;
493
513
  const declaration = this.file.values.declarationOf(callee.name, context.scopes);
494
514
  const hook = declaration === null
495
- ? HOOKS.has(callee.name) && callee.name
515
+ ? isHook(callee.name) && callee.name
496
516
  : this.bindings.declarations.has(declaration) && this.bindings.hooks.get(callee.name);
497
517
  return hook ? this.hook(call, hook) : null;
498
518
  }
499
519
  hook(call, name) {
500
520
  const fn = findFunction(call.arguments);
501
521
  const index = HOOKS.get(name);
502
- return { kind: 'hook', fn, testContext: index === null ? null : skipNames(fn, index) };
522
+ return {
523
+ kind: 'hook',
524
+ name,
525
+ fn,
526
+ testContext: index === null ? null : contextNames(fn, index),
527
+ fixtures: fixturesOf(fn, index, this.file.version),
528
+ };
503
529
  }
504
530
  contextSkip(call, context) {
505
531
  const names = context.testContext;
506
532
  if (!names)
507
533
  return null;
508
- const callee = unwrap(call.callee);
509
- const isSkip = callee.type === 'Identifier'
510
- ? names.functions.has(callee.name)
511
- : callee.type === 'MemberExpression' &&
512
- propertyName(callee) === 'skip' &&
513
- callee.object.type === 'Identifier' &&
514
- names.objects.has(callee.object.name);
515
- if (!isSkip)
534
+ if (atLeast(this.file.version, 3, 2) && callsContext(call, names, 'annotate'))
535
+ return this.annotate(call, context);
536
+ if (!callsContext(call, names, 'skip'))
516
537
  return null;
517
538
  const [first, second] = call.arguments;
518
539
  // skip(), skip(note) or skip(condition, note); before Vitest 3.1 the first argument is always the note.
@@ -527,6 +548,29 @@ export class VitestAdapter {
527
548
  problems: reason.problem ? [reason.problem] : [],
528
549
  };
529
550
  }
551
+ /**
552
+ * `annotate(message, type?)` from the test context (3.2), as an annotation of type `notice` when no type is given; the
553
+ * second argument may also be an attachment.
554
+ */
555
+ annotate(call, context) {
556
+ const args = call.arguments.map((arg) => arg.type === 'SpreadElement' ? null : this.file.values.resolve(arg, context.scopes));
557
+ const [message, second] = args;
558
+ // A second argument that isn't a string is an attachment, given in place of the type.
559
+ let type = 'notice';
560
+ if (args.length > 1 && !second?.ok)
561
+ type = null;
562
+ else if (second?.ok && typeof second.value === 'string')
563
+ type = second.value;
564
+ if (message?.ok && typeof message.value === 'string' && type !== null) {
565
+ const annotation = { type, description: message.value, lineStart: this.file.source.line(call.start) };
566
+ return { kind: 'annotations', items: [annotation], problems: [] };
567
+ }
568
+ const source = snippet(text(this.file, call));
569
+ const unresolved = [message, second].find((item) => item && !item.ok);
570
+ const from = unresolved && !unresolved.ok ? unresolved.importedFrom : null;
571
+ const missing = problem('unresolved-annotation', call, `Annotation ${source}${origin(from)} could not be worked out; it is missing.`, source);
572
+ return { kind: 'annotations', items: [], problems: [missing] };
573
+ }
530
574
  declaration(call, kind, links, context) {
531
575
  const args = call.arguments;
532
576
  const callee = snippet(text(this.file, call.callee));
@@ -548,6 +592,7 @@ export class VitestAdapter {
548
592
  // (title, fn, timeout), (title, options, fn) or the removed (title, fn, options)
549
593
  let options;
550
594
  let body;
595
+ let timeout;
551
596
  let refusal = null;
552
597
  const [second, third] = [args.at(1), args.at(2)];
553
598
  if (args.some((arg) => arg.type === 'SpreadElement')) {
@@ -565,9 +610,23 @@ export class VitestAdapter {
565
610
  }
566
611
  options = third;
567
612
  }
613
+ else if (third === undefined ||
614
+ isFunctionNode(second) ||
615
+ namedFunction(this.file, context.scopes, second) ||
616
+ this.file.values.resolve(third, context.scopes).ok) {
617
+ // With imported options and a body passed by name, either could be the body, so the third is no timeout.
618
+ timeout = third;
619
+ }
568
620
  }
569
621
  const fn = isFunctionNode(body) ? body : null;
570
622
  const read = this.readDeclarationOptions(options, context, problems);
623
+ if (timeout) {
624
+ const node = timeout;
625
+ const option = { node, resolved: this.file.values.resolve(node, context.scopes) };
626
+ const value = readCount(this.file, 'timeout', option, problems);
627
+ if (value !== null)
628
+ read.details.run.timeout = value;
629
+ }
571
630
  const chain = new Set(links.filter((link) => MODIFIERS[kind].has(link.name)).map((link) => link.name));
572
631
  const flags = new Set(chain);
573
632
  let conditions = links.filter((link) => CONDITIONS.has(link.name)).map((link) => this.linkCondition(link, context));
@@ -632,7 +691,14 @@ export class VitestAdapter {
632
691
  declared = { kind: 'describe', ...base, isShuffled: flags.has('shuffle'), passesDown };
633
692
  }
634
693
  else {
635
- declared = { kind: 'test', ...base, testContext: skipNames(fn, table?.name === 'for' ? 1 : table ? null : 0) };
694
+ const index = table?.name === 'for' ? 1 : table ? null : 0;
695
+ declared = {
696
+ kind: 'test',
697
+ ...base,
698
+ testContext: contextNames(base.hashed, index),
699
+ fixtures: fixturesOf(base.hashed, index, this.file.version),
700
+ usesBench: atLeast(this.file.version, 5, 0) && destructuredNames(base.hashed, index).includes('bench'),
701
+ };
636
702
  }
637
703
  return refusal ? refused(call, refusal, context, () => declared) : declared;
638
704
  }
@@ -680,7 +746,7 @@ export class VitestAdapter {
680
746
  return fails;
681
747
  }
682
748
  readDeclarationOptions(node, context, problems) {
683
- const details = { tags: [], annotations: [], locks: [], meta: null };
749
+ const details = { tags: [], annotations: [], locks: [], meta: null, run: {} };
684
750
  const result = {
685
751
  flags: new Map(),
686
752
  concurrent: null,
@@ -723,8 +789,46 @@ export class VitestAdapter {
723
789
  const meta = options.values.get('meta');
724
790
  if (meta)
725
791
  details.meta = this.meta(meta, context, problems);
792
+ for (const key of ['timeout', 'repeats']) {
793
+ const option = options.values.get(key);
794
+ const value = option ? readCount(this.file, key, option, problems) : null;
795
+ if (value !== null)
796
+ details.run[key] = value;
797
+ }
798
+ const retry = options.values.get('retry');
799
+ if (retry)
800
+ this.retry(retry, details, context, problems);
726
801
  return result;
727
802
  }
803
+ /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`; the runner ignores an object before. */
804
+ retry(option, details, context, problems) {
805
+ const node = unwrap(option.node);
806
+ if (node.type !== 'ObjectExpression') {
807
+ const count = readCount(this.file, 'retry', option, problems);
808
+ if (count !== null)
809
+ details.run.retry = { count, delay: null, condition: null };
810
+ return;
811
+ }
812
+ if (!atLeast(this.file.version, 4, 1))
813
+ return;
814
+ const values = readOptions(this.file, context.scopes, node);
815
+ problems.push(...unresolvedOptions(this.file, values, 'the retry is not applied'));
816
+ const read = (key) => {
817
+ const value = values.values.get(key);
818
+ return value ? readCount(this.file, `retry.${key}`, value, problems) : undefined;
819
+ };
820
+ const count = read('count');
821
+ const delay = read('delay');
822
+ if (count === null)
823
+ return;
824
+ const condition = values.values.get('condition');
825
+ details.run.retry = {
826
+ // Vitest retries 0 times when the object has no count.
827
+ count: count ?? 0,
828
+ delay: delay ?? null,
829
+ condition: condition ? snippet(text(this.file, condition.node)) : null,
830
+ };
831
+ }
728
832
  meta(option, context, problems) {
729
833
  const entries = readOptions(this.file, context.scopes, option.node);
730
834
  problems.push(...unresolvedOptions(this.file, entries, 'its meta values are missing'));
@@ -781,19 +885,32 @@ function templateRows(template) {
781
885
  function isMetaValue(value) {
782
886
  return value !== undefined;
783
887
  }
784
- function skipNames(fn, index) {
888
+ function contextNames(fn, index) {
785
889
  const parameter = index === null ? undefined : fn?.params[index];
786
890
  if (!parameter)
787
891
  return null;
788
- const names = { objects: new Set(), functions: new Set() };
892
+ const names = { objects: new Set(), skip: new Set(), annotate: new Set() };
789
893
  if (parameter.type === 'Identifier')
790
894
  names.objects.add(parameter.name);
791
895
  if (parameter.type === 'ObjectPattern') {
792
896
  for (const property of parameter.properties) {
793
- if (property.type === 'Property' && propertyKey(property) === 'skip' && property.value.type === 'Identifier') {
794
- names.functions.add(property.value.name);
897
+ const key = property.type === 'Property' ? propertyKey(property) : null;
898
+ if ((key === 'skip' || key === 'annotate') &&
899
+ property.type === 'Property' &&
900
+ property.value.type === 'Identifier') {
901
+ names[key].add(property.value.name);
795
902
  }
796
903
  }
797
904
  }
798
905
  return names;
799
906
  }
907
+ /** Whether a call is `name(...)` for a context function taken out of the context, or `context.name(...)`. */
908
+ function callsContext(call, names, member) {
909
+ const callee = unwrap(call.callee);
910
+ return callee.type === 'Identifier'
911
+ ? names[member].has(callee.name)
912
+ : callee.type === 'MemberExpression' &&
913
+ propertyName(callee) === member &&
914
+ callee.object.type === 'Identifier' &&
915
+ names.objects.has(callee.object.name);
916
+ }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,4 @@ export { scanSource } from './scan-source.ts';
3
3
  export { filterInventory, pickTestFields } from './filter.ts';
4
4
  export type { InventoryFilter, PickedInventory } from './filter.ts';
5
5
  export type { CommentsMode, ScanOptions, ScanSourceOptions } from './options.ts';
6
- export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, InventoryLine, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
6
+ export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, FileRecord, Framework, HookCounts, Inventory, InventoryLine, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
package/dist/inventory.js CHANGED
@@ -10,6 +10,7 @@ export function createInventory(framework, frameworkVersion, fileCount, files, p
10
10
  framework,
11
11
  frameworkVersion,
12
12
  summary: summarize(fileCount, suites, tests, diagnostics),
13
+ files: files.map((file) => file.file),
13
14
  suites,
14
15
  tests,
15
16
  diagnostics,
package/dist/json.js CHANGED
@@ -48,6 +48,8 @@ export async function writeInventoryLines(inventory, write) {
48
48
  const line = (type, record) => emit(`${JSON.stringify({ type, ...record })}\n`);
49
49
  const { schemaVersion, tool, framework, frameworkVersion } = inventory;
50
50
  await line('inventory', { schemaVersion, tool, framework, frameworkVersion });
51
+ for (const file of inventory.files)
52
+ await line('file', file);
51
53
  for (const suite of inventory.suites)
52
54
  await line('suite', suite);
53
55
  for (const test of inventory.tests)
package/dist/model.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
- import type { Annotation, DiagnosticCode, DiagnosticLevel, MetaValue } from './types.ts';
3
+ import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
4
4
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
5
5
  export type Mode = 'parallel' | 'serial' | 'default';
6
6
  /** A state set by a declaration, a describe or a call such as `test.skip(condition)`. */
@@ -45,6 +45,22 @@ export interface Details {
45
45
  annotations: Annotation[];
46
46
  locks: string[];
47
47
  meta: Record<string, MetaValue> | null;
48
+ run: RunSettings;
49
+ }
50
+ /** Run settings written in one place; a key that is missing is not set there. */
51
+ export interface RunSettings {
52
+ timeout?: number;
53
+ /** A retry setting replaces the whole `retry` of the describes around, delay and condition included. */
54
+ retry?: {
55
+ count: number;
56
+ delay: number | null;
57
+ condition: string | null;
58
+ };
59
+ repeats?: number;
60
+ /** Playwright `test.slow()`: the conditions of the calls, any of which makes the test slow; null when one always does. */
61
+ slow?: {
62
+ conditions: string[] | null;
63
+ };
48
64
  }
49
65
  interface Declaration {
50
66
  title: Title | null;
@@ -65,6 +81,10 @@ export interface TestDeclaration extends Declaration {
65
81
  title: Title;
66
82
  /** Framework-specific names through which the test body can change its own state, such as Vitest's `context.skip`. */
67
83
  testContext: unknown;
84
+ /** Names the test function takes from its context, without the framework's own keys. */
85
+ fixtures: string[];
86
+ /** Uses Vitest 5's `bench` fixture. */
87
+ usesBench: boolean;
68
88
  }
69
89
  export interface SuiteDeclaration extends Declaration {
70
90
  kind: 'describe';
@@ -85,16 +105,33 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
85
105
  hasDynamicReason: boolean;
86
106
  problems: Problem[];
87
107
  }
88
- /** `test.describe.configure()`: a mode, locks (Playwright 1.64), or both. */
108
+ /** `test.describe.configure()`: a mode, locks (Playwright 1.64), a timeout and retries. */
89
109
  | {
90
110
  kind: 'configure';
91
111
  mode: Mode | null;
92
112
  locks: string[];
113
+ run: RunSettings;
114
+ problems: Problem[];
115
+ }
116
+ /** Playwright `test.setTimeout()` or `test.slow()`, which apply where they are called. */
117
+ | {
118
+ kind: 'run';
119
+ settings: RunSettings;
120
+ /** Source text of the first argument. */ argument: string | null;
93
121
  problems: Problem[];
94
122
  } | {
95
123
  kind: 'hook';
124
+ name: keyof HookCounts;
96
125
  fn: FunctionNode | null;
97
126
  testContext: unknown;
127
+ fixtures: string[];
128
+ }
129
+ /** Playwright `test.step()` or `test.step.skip()`. */
130
+ | {
131
+ kind: 'step';
132
+ title: Title;
133
+ isSkipped: boolean;
134
+ fn: FunctionNode | null;
98
135
  } | {
99
136
  kind: 'annotations';
100
137
  items: Annotation[];
package/dist/records.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Extraction } from './extract.ts';
1
+ import { type Extraction } from './extract.ts';
2
2
  import type { ParsedFile } from './parse.ts';
3
- import type { Diagnostic, Framework, SuiteRecord, TestRecord } from './types.ts';
3
+ import type { Diagnostic, FileRecord, Framework, HookCounts, SuiteRecord, TestRecord } from './types.ts';
4
4
  /** @inline */
5
5
  export type CommentsMode = 'all' | 'non-active' | 'none';
6
6
  export interface FileRecords {
7
+ file: FileRecord;
7
8
  suites: SuiteRecord[];
8
9
  tests: TestRecord[];
9
10
  diagnostics: Diagnostic[];
@@ -13,7 +14,11 @@ interface RecordOptions {
13
14
  framework: Framework;
14
15
  comments: CommentsMode;
15
16
  isTypeTest: boolean;
17
+ /** A Vitest 5 benchmark file, such as `*.bench.ts`. */
18
+ isBenchmarkFile: boolean;
16
19
  }
20
+ /** The record of a scanned file, from its records and the hooks at its top level. */
21
+ export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
17
22
  /** An error for each id used by more than one record. Ordinals keep ids apart, so only a hash collision causes one. */
18
23
  export declare function duplicateIdDiagnostics(records: readonly {
19
24
  id: string;
package/dist/records.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
+ import { anySlow, eitherCondition, noHooks, } from "./extract.js";
2
3
  import { problem } from "./frameworks/common.js";
3
4
  import { hashNodes, shortHash } from "./hash.js";
4
5
  /**
@@ -29,6 +30,47 @@ function bodyNodes(fn) {
29
30
  const body = fn.body;
30
31
  return body.type === 'BlockStatement' ? body.body : [body];
31
32
  }
33
+ function addHooks(scopes) {
34
+ const total = noHooks();
35
+ for (const scope of scopes) {
36
+ for (const name of Object.keys(total))
37
+ total[name] += scope.hooks[name];
38
+ }
39
+ return total;
40
+ }
41
+ /**
42
+ * The run settings that apply, from the places that set them, nearest first: the first that sets a key wins, and any
43
+ * `test.slow()` makes the test slow.
44
+ */
45
+ function runKeys(places) {
46
+ const first = (key) => places.find((place) => place[key] !== undefined)?.[key];
47
+ const retry = first('retry');
48
+ const slows = places.flatMap((place) => place.slow ?? []);
49
+ return {
50
+ timeout: first('timeout') ?? null,
51
+ isSlow: slows.length > 0,
52
+ slowCondition: eitherCondition(anySlow(slows).conditions ?? []) || null,
53
+ retries: retry?.count ?? null,
54
+ retryDelay: retry?.delay ?? null,
55
+ retryCondition: retry?.condition ?? null,
56
+ repeats: first('repeats') ?? null,
57
+ };
58
+ }
59
+ /** The record of a scanned file, from its records and the hooks at its top level. */
60
+ export function fileRecord(relativeFilePath, hooks, records) {
61
+ return {
62
+ relativeFilePath,
63
+ fileName: relativeFilePath.slice(relativeFilePath.lastIndexOf('/') + 1),
64
+ hooks,
65
+ suiteCount: records.suites.length,
66
+ testCount: records.tests.length,
67
+ };
68
+ }
69
+ /** Whether a function's block holds no statements; comments are not statements. */
70
+ function hasNoStatements(fn) {
71
+ const body = fn?.body;
72
+ return body?.type === 'BlockStatement' && body.body.length === 0;
73
+ }
32
74
  /** The number of times the runner repeats a test: its own cases times those of each describe table around it. */
33
75
  function repetitions(counts) {
34
76
  let product = 1;
@@ -71,7 +113,12 @@ class RecordBuilder {
71
113
  this.diagnostics.push(...duplicateIdDiagnostics([...suites, ...tests], this.options.relativeFilePath));
72
114
  // Diagnostics for the whole file have no line, which sorts as 0, before the others.
73
115
  const diagnostics = this.diagnostics.sort((a, b) => Number(a.lineStart) - Number(b.lineStart) || Number(a.columnStart) - Number(b.columnStart));
74
- return { suites, tests, diagnostics };
116
+ return {
117
+ file: fileRecord(this.options.relativeFilePath, extraction.hooks, { suites, tests }),
118
+ suites,
119
+ tests,
120
+ diagnostics,
121
+ };
75
122
  }
76
123
  diagnostic(item, testId = null) {
77
124
  return toDiagnostic(item, this.options.relativeFilePath, this.parsed.source, testId);
@@ -136,6 +183,8 @@ class RecordBuilder {
136
183
  annotations: scope.details.annotations,
137
184
  locks: unique(scope.details.locks),
138
185
  meta: scope.details.meta,
186
+ hooks: { ...scope.hooks },
187
+ ...runKeys([scope.details.run]),
139
188
  };
140
189
  }
141
190
  test(pending) {
@@ -172,6 +221,10 @@ class RecordBuilder {
172
221
  const tables = [declaration, ...describes.map((scope) => suiteOf(scope).declaration)].filter((item) => item.isParameterized);
173
222
  for (const item of pending.problems)
174
223
  this.diagnostics.push(this.diagnostic(item, id));
224
+ if (declaration.usesBench && !this.options.isBenchmarkFile) {
225
+ const message = 'The test uses the bench fixture, which Vitest only provides in benchmark files such as *.bench.ts; elsewhere the test fails.';
226
+ this.diagnostics.push(this.diagnostic(problem('bench-outside-benchmark-file', located, message), id));
227
+ }
175
228
  if (pending.isInLoop) {
176
229
  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));
177
230
  }
@@ -221,6 +274,21 @@ class RecordBuilder {
221
274
  ...declaration.details.locks,
222
275
  ]),
223
276
  meta: Object.keys(meta).length > 0 ? meta : null,
277
+ hooks: addHooks(scopes),
278
+ // 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),
284
+ ]),
285
+ fixtures: unique([...declaration.fixtures, ...[...scopes].reverse().flatMap((scope) => scope.hookFixtures)]),
286
+ expectCount: pending.expectCount,
287
+ expectedAssertions: pending.expectedAssertions,
288
+ expectsAssertions: pending.expectsAssertions,
289
+ hasEmptyBody: hasNoStatements(declaration.hashed),
290
+ steps: pending.steps,
291
+ isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
224
292
  isParameterized: tables.length > 0,
225
293
  caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
226
294
  isInLoop: pending.isInLoop,
@@ -13,5 +13,7 @@ export interface FileSettings {
13
13
  export interface FileResult extends FileRecords {
14
14
  usesGlobals: boolean;
15
15
  }
16
+ /** The records of a file the scan read no tests from. */
17
+ export declare function noRecords(relativeFilePath: string): Omit<FileRecords, 'diagnostics'>;
16
18
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
17
19
  export declare function scanFile(code: string, relativeFilePath: string, settings: FileSettings): FileResult;
package/dist/scan-file.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
- import { extract } from "./extract.js";
2
+ import { extract, noHooks } from "./extract.js";
3
3
  import { parseModule } from "./frameworks/bindings.js";
4
- import { problem } from "./frameworks/common.js";
4
+ import { atLeast, problem } from "./frameworks/common.js";
5
5
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
6
6
  import { VitestAdapter } from "./frameworks/vitest.js";
7
7
  import { parseFile } from "./parse.js";
8
- import { buildRecords } from "./records.js";
8
+ import { buildRecords, fileRecord } from "./records.js";
9
9
  import { snippet } from "./source.js";
10
10
  import { Values } from "./values.js";
11
11
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
12
+ const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
12
13
  /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
13
14
  function frame(parsed, relativeFilePath, settings, readModule) {
14
15
  const file = { source: parsed.source, values: new Values(), version: settings.version };
@@ -17,6 +18,10 @@ function frame(parsed, relativeFilePath, settings, readModule) {
17
18
  : new VitestAdapter(parsed, file, readModule);
18
19
  return { parsed, adapter, values: file.values, readModule, relativeFilePath };
19
20
  }
21
+ /** The records of a file the scan read no tests from. */
22
+ export function noRecords(relativeFilePath) {
23
+ return { file: fileRecord(relativeFilePath, noHooks(), { suites: [], tests: [] }), suites: [], tests: [] };
24
+ }
20
25
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
21
26
  export function scanFile(code, relativeFilePath, settings) {
22
27
  try {
@@ -27,7 +32,7 @@ export function scanFile(code, relativeFilePath, settings) {
27
32
  diagnostics.push(toDiagnostic(problem('parse-error', { start, end }, `The file could not be parsed: ${message}`, snippet(parsed.source.slice(start, end)) || null), relativeFilePath, parsed.source));
28
33
  // The parser keeps what it could read after some errors; after others it returns nothing.
29
34
  if (parsed.program.body.length === 0)
30
- return { suites: [], tests: [], diagnostics, usesGlobals: false };
35
+ return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
31
36
  }
32
37
  const main = frame(parsed, relativeFilePath, settings, settings.readModule);
33
38
  const extraction = extract(main, (module) => {
@@ -39,6 +44,8 @@ export function scanFile(code, relativeFilePath, settings) {
39
44
  framework: settings.framework,
40
45
  comments: settings.comments,
41
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),
42
49
  });
43
50
  return {
44
51
  ...records,
@@ -50,8 +57,7 @@ export function scanFile(code, relativeFilePath, settings) {
50
57
  const raw = error instanceof Error ? error.message : String(error);
51
58
  const message = settings.root === null ? raw : raw.replaceAll(settings.root, '.');
52
59
  return {
53
- suites: [],
54
- tests: [],
60
+ ...noRecords(relativeFilePath),
55
61
  diagnostics: [
56
62
  fileDiagnostic('internal-error', relativeFilePath, `Reading the file failed unexpectedly: ${message}`),
57
63
  ],
package/dist/scan.js CHANGED
@@ -7,7 +7,7 @@ import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
7
7
  import { createInventory } from "./inventory.js";
8
8
  import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
9
9
  import { moduleResolver } from "./resolve.js";
10
- import { scanFile } from "./scan-file.js";
10
+ import { noRecords, scanFile } from "./scan-file.js";
11
11
  import { globalsCheck } from "./vitest-config.js";
12
12
  function versionDiagnostics(framework, version, source, range) {
13
13
  const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
@@ -86,8 +86,7 @@ export async function scan(options) {
86
86
  }
87
87
  catch (error) {
88
88
  results.push({
89
- suites: [],
90
- tests: [],
89
+ ...noRecords(relativeFilePath),
91
90
  diagnostics: [
92
91
  fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
93
92
  ],
package/dist/summary.js CHANGED
@@ -27,6 +27,7 @@ export function summarize(fileCount, suites, tests, diagnostics) {
27
27
  inConditionCount: count('isInCondition'),
28
28
  inFunctionCount: count('isInFunction'),
29
29
  typeTestCount: count('isTypeTest'),
30
+ benchmarkCount: count('isBenchmark'),
30
31
  diagnosticCount: diagnostics.length,
31
32
  tagCounts: countBy(tests, (test) => test.tags),
32
33
  annotationCounts: countBy(tests, (test) => test.annotations.map((annotation) => annotation.type)),