test-inventory 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- registeredAt: null,
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
- registeredAt: context.registeredAt ?? call.start,
635
+ registeredBy: context.registeredBy ?? call,
632
636
  });
633
637
  });
634
638
  this.callStack.pop();
@@ -749,13 +753,16 @@ class Walker {
749
753
  mergeRun(call.target.hookRun, settings);
750
754
  }
751
755
  }
752
- /** Counts `expect()`, `expect.soft()` and `expect.poll()`, and reads `expect.assertions(n)` and `expect.hasAssertions()`. */
756
+ /**
757
+ * Counts `expect()`, `expect.soft()`, `expect.poll()` and `expect.element()`, and reads `expect.assertions(n)` and
758
+ * `expect.hasAssertions()`.
759
+ */
753
760
  expectCall(call, test) {
754
761
  const callee = unwrap(call.callee);
755
762
  const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
756
763
  if (root.type !== 'Identifier' || root.name !== 'expect')
757
764
  return;
758
- if (member === 'call' || member === 'soft' || member === 'poll')
765
+ if (member === 'call' || member === 'soft' || member === 'poll' || member === 'element')
759
766
  test.expectCount += 1;
760
767
  if (member === 'hasAssertions')
761
768
  test.expectsAssertions = true;
@@ -926,7 +933,9 @@ class Walker {
926
933
  call,
927
934
  site: this.site,
928
935
  declaredAt: this.declaredAt(call),
929
- order: context.registeredAt ?? call.start,
936
+ frame: this.frame,
937
+ registeredBy: context.registeredBy,
938
+ order: (context.registeredBy ?? call).start,
930
939
  states: [...declaration.states],
931
940
  annotations: [...declaration.details.annotations],
932
941
  isInLoop: context.isInLoop,
@@ -976,6 +985,8 @@ class Walker {
976
985
  hooks: noHooks(),
977
986
  hookRun: {},
978
987
  hookFixtures: [],
988
+ setupFixtures: [],
989
+ takesAllFixtures: false,
979
990
  suite: null,
980
991
  };
981
992
  const suite = {
@@ -984,7 +995,9 @@ class Walker {
984
995
  call,
985
996
  site: this.site,
986
997
  declaredAt: this.declaredAt(call),
987
- order: context.registeredAt ?? call.start,
998
+ frame: this.frame,
999
+ registeredBy: context.registeredBy,
1000
+ order: (context.registeredBy ?? call).start,
988
1001
  cuts: [],
989
1002
  isInCondition: context.isInCondition,
990
1003
  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 { kind: 'hook', name: key, fn, testContext: null, fixtures: destructuredNames(fn, 0) };
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
  };
@@ -75,7 +75,7 @@ export declare class VitestAdapter implements Adapter {
75
75
  /** Vitest's order: `only` wins over `skip`, which wins over `todo`. */
76
76
  private states;
77
77
  private readDeclarationOptions;
78
- /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`; the runner ignores an object before. */
78
+ /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`. */
79
79
  private retry;
80
80
  private meta;
81
81
  private caseCount;
@@ -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)
@@ -27,6 +27,7 @@ const MODIFIERS = {
27
27
  };
28
28
  const TABLES = new Set(['each', 'for']);
29
29
  const CONDITIONS = new Set(['skipIf', 'runIf']);
30
+ const STATE_FLAGS = ['only', 'todo', 'fails'];
30
31
  const DERIVATIONS = new Set(['extend', 'override', 'scoped']);
31
32
  /** Hooks, with the position of the test context among their callback's parameters. */
32
33
  const HOOKS = new Map([
@@ -528,6 +529,7 @@ export class VitestAdapter {
528
529
  fn,
529
530
  testContext: index === null ? null : contextNames(fn, index),
530
531
  fixtures: fixturesOf(fn, index, this.file.version),
532
+ takesAllFixtures: takesWhole(fn, index),
531
533
  };
532
534
  }
533
535
  contextSkip(call, context) {
@@ -633,6 +635,17 @@ export class VitestAdapter {
633
635
  const chain = new Set(links.filter((link) => MODIFIERS[kind].has(link.name)).map((link) => link.name));
634
636
  const flags = new Set(chain);
635
637
  let conditions = links.filter((link) => CONDITIONS.has(link.name)).map((link) => this.linkCondition(link, context));
638
+ // From Vitest 5.0.5 a test's `skip`, `skipIf` and `runIf` each set its `skip`, so the last of them decides, and a
639
+ // `skipIf` or `runIf` replaces the skip of a tag or of a describe around. The scan applies that to all of Vitest 5.
640
+ const lastSkip = links.filter((link) => link.name === 'skip' || CONDITIONS.has(link.name)).at(-1);
641
+ const skipsByChain = kind === 'test' && lastSkip !== undefined && atLeast(this.file.version, 5, 0);
642
+ const conditionSetsSkip = skipsByChain && CONDITIONS.has(lastSkip.name);
643
+ if (skipsByChain) {
644
+ flags.delete('skip');
645
+ conditions = conditionSetsSkip ? [this.linkCondition(lastSkip, context)] : [];
646
+ if (!conditionSetsSkip)
647
+ flags.add('skip');
648
+ }
636
649
  // A test's own options win over its chain, so `test.skip('title', { skip: false }, fn)` runs. The chain wins for a
637
650
  // test's mode, and for everything on a describe.
638
651
  for (const [flag, value] of read.flags) {
@@ -652,7 +665,7 @@ export class VitestAdapter {
652
665
  // win. A `skip` passed down skips them, and turns todo into skip, as skip goes before todo.
653
666
  const line = this.file.source.line(call.start);
654
667
  const around = context.passedDown;
655
- const passed = read.skip ? null : around;
668
+ const passed = read.skip || conditionSetsSkip ? null : around;
656
669
  const skips = passed?.condition.applies === 'always' && !flags.has('only');
657
670
  const table = links.find((link) => TABLES.has(link.name)) ?? null;
658
671
  const isTodo = flags.has('todo') || body === undefined;
@@ -701,7 +714,12 @@ export class VitestAdapter {
701
714
  ...base,
702
715
  testContext: contextNames(base.hashed, index),
703
716
  fixtures: fixturesOf(base.hashed, index, this.file.version),
717
+ takesAllFixtures: takesWhole(base.hashed, index),
704
718
  usesBench: atLeast(this.file.version, 5, 0) && destructuredNames(base.hashed, index).includes('bench'),
719
+ ownStateOptions: [
720
+ ...(read.skip || conditionSetsSkip ? ['skip'] : []),
721
+ ...STATE_FLAGS.filter((flag) => read.flags.has(flag)),
722
+ ],
705
723
  };
706
724
  }
707
725
  return refusal ? refused(call, refusal, context, () => declared) : declared;
@@ -761,8 +779,14 @@ export class VitestAdapter {
761
779
  return result;
762
780
  const options = readOptions(this.file, context.scopes, node);
763
781
  problems.push(...unresolvedOptions(this.file, options, 'its flags, tags and meta are missing'));
782
+ // From Vitest 5.0.5 an option set to `undefined` replaces nothing; the scan applies that to all of Vitest 5.
783
+ const given = (key) => {
784
+ const option = options.values.get(key);
785
+ const isUndefined = option?.resolved.ok === true && option.resolved.value === undefined;
786
+ return isUndefined && atLeast(this.file.version, 5, 0) ? undefined : option;
787
+ };
764
788
  for (const flag of ['only', 'todo', 'fails', 'concurrent', 'sequential', 'shuffle']) {
765
- const option = options.values.get(flag);
789
+ const option = given(flag);
766
790
  if (option && !option.resolved.ok) {
767
791
  const { importedFrom } = option.resolved;
768
792
  problems.push(unresolvedOption(this.file, flag, option.node, importedFrom, 'it is not applied'));
@@ -774,7 +798,7 @@ export class VitestAdapter {
774
798
  result.flags.set(flag, Boolean(option.resolved.value));
775
799
  }
776
800
  }
777
- const skip = options.values.get('skip');
801
+ const skip = given('skip');
778
802
  if (skip) {
779
803
  result.skip = skip.resolved.ok
780
804
  ? { applies: skip.resolved.value ? 'always' : 'never', text: null }
@@ -804,7 +828,7 @@ export class VitestAdapter {
804
828
  this.retry(retry, details, context, problems);
805
829
  return result;
806
830
  }
807
- /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`; the runner ignores an object before. */
831
+ /** `retry` as a number, or since Vitest 4.1 as `{ count, delay, condition }`. */
808
832
  retry(option, details, context, problems) {
809
833
  const node = unwrap(option.node);
810
834
  if (node.type !== 'ObjectExpression') {
@@ -813,8 +837,14 @@ export class VitestAdapter {
813
837
  details.run.retry = { count, delay: null, condition: null };
814
838
  return;
815
839
  }
816
- if (!atLeast(this.file.version, 4, 1))
840
+ if (!atLeast(this.file.version, 4, 1)) {
841
+ // Before 4.1 the runner compares the attempt number with the object, so it never runs the test.
842
+ const source = snippet(text(this.file, node));
843
+ const message = `Option retry: ${source} is an object, which Vitest reads from 4.1; before, it never runs the test.`;
844
+ problems.push(problem('unresolved-option', node, message, source));
845
+ details.run.retry = null;
817
846
  return;
847
+ }
818
848
  const values = readOptions(this.file, context.scopes, node);
819
849
  problems.push(...unresolvedOptions(this.file, values, 'the retry is not applied'));
820
850
  const read = (key) => {
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
@@ -2,6 +2,8 @@ import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
3
  import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
4
4
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
5
+ /** The Vitest options that set a test's state. */
6
+ export type StateOption = 'skip' | 'only' | 'todo' | 'fails';
5
7
  export type Mode = 'parallel' | 'serial' | 'default';
6
8
  /** A state set by a declaration, a describe or a call such as `test.skip(condition)`. */
7
9
  export interface StateSetting {
@@ -51,12 +53,15 @@ export interface Details {
51
53
  /** Run settings written in one place; a key that is missing is not set there. */
52
54
  export interface RunSettings {
53
55
  timeout?: number;
54
- /** A retry setting replaces the whole `retry` of the describes around, delay and condition included. */
56
+ /**
57
+ * A retry setting replaces the whole `retry` of the describes around, delay and condition included; null when it is
58
+ * set to a value the runner can't use.
59
+ */
55
60
  retry?: {
56
61
  count: number;
57
62
  delay: number | null;
58
63
  condition: string | null;
59
- };
64
+ } | null;
60
65
  repeats?: number;
61
66
  /** Playwright `test.slow()`: the conditions of the calls, any of which makes the test slow; null when one always does. */
62
67
  slow?: {
@@ -86,8 +91,12 @@ export interface TestDeclaration extends Declaration {
86
91
  testContext: unknown;
87
92
  /** Names the test function takes from its context, without the framework's own keys. */
88
93
  fixtures: string[];
94
+ /** The test function takes its fixtures whole, or keeps the rest of them, so it may use any. */
95
+ takesAllFixtures: boolean;
89
96
  /** Uses Vitest 5's `bench` fixture. */
90
97
  usesBench: boolean;
98
+ /** Vitest: the state options the test sets itself, to `false` too, which replace those of its tags. */
99
+ ownStateOptions?: StateOption[];
91
100
  }
92
101
  export interface SuiteDeclaration extends Declaration {
93
102
  kind: 'describe';
@@ -128,6 +137,7 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
128
137
  fn: FunctionNode | null;
129
138
  testContext: unknown;
130
139
  fixtures: string[];
140
+ takesAllFixtures: boolean;
131
141
  }
132
142
  /** Playwright `test.step()` or `test.step.skip()`. */
133
143
  | {
@@ -183,6 +193,8 @@ export interface Returns {
183
193
  /** A module read to see whether it exports a custom `test` object. */
184
194
  export interface ModuleSource {
185
195
  code: string;
196
+ /** The file's bytes when it isn't UTF-8 text, such as an image. */
197
+ bytes?: Uint8Array;
186
198
  relativeFilePath: string;
187
199
  /** Reads the modules this module imports in turn. */
188
200
  readModule: ModuleReader;
package/dist/parse.d.ts CHANGED
@@ -15,6 +15,12 @@ export interface ParsedFile {
15
15
  end: number;
16
16
  } | null;
17
17
  }
18
+ /**
19
+ * The offset of the first bracket nested more than `limit` deep, or null. Parentheses, brackets, braces and template
20
+ * substitutions count; strings, comments, regular expressions and template text are skipped. A closing bracket closes
21
+ * up to the one it matches, so a literal read wrongly unbalances only the brackets around it.
22
+ */
23
+ export declare function tooDeeplyNested(text: string, limit: number): number | null;
18
24
  /** The first parser error, located at its first label, or at the start of the file when it has none. */
19
25
  export declare function firstError(errors: readonly OxcError[]): ParsedFile['error'];
20
26
  export declare function parseFile(fileName: string, code: string): ParsedFile;
package/dist/parse.js CHANGED
@@ -7,6 +7,200 @@ function parserOptions(fileName) {
7
7
  const sourceType = extension.startsWith('.c') ? 'commonjs' : extension.startsWith('.m') ? 'module' : 'unambiguous';
8
8
  return { lang, sourceType, preserveParens: false };
9
9
  }
10
+ /**
11
+ * How deep brackets may nest. oxc-parser recurses on the native stack, which no catch can stop: brackets about 2,400
12
+ * deep crash a worker thread's 4 MB stack and 4,800 the main thread's 8 MB. Of the 10,000 files in this package's
13
+ * development dependencies, minified bundles included, the deepest nests 34.
14
+ */
15
+ const MAX_NESTING = 500;
16
+ /** Words after which a `/` starts a regular expression rather than a division. */
17
+ const BEFORE_REGEX = new Set([
18
+ 'return',
19
+ 'typeof',
20
+ 'instanceof',
21
+ 'in',
22
+ 'of',
23
+ 'new',
24
+ 'delete',
25
+ 'void',
26
+ 'throw',
27
+ 'case',
28
+ 'do',
29
+ 'else',
30
+ 'yield',
31
+ 'await',
32
+ ]);
33
+ const Char = {
34
+ Tab: 9,
35
+ LineFeed: 10,
36
+ CarriageReturn: 13,
37
+ Space: 32,
38
+ DoubleQuote: 34,
39
+ Dollar: 36,
40
+ SingleQuote: 39,
41
+ OpenParen: 40,
42
+ CloseParen: 41,
43
+ Star: 42,
44
+ Slash: 47,
45
+ LessThan: 60,
46
+ GreaterThan: 62,
47
+ OpenBracket: 91,
48
+ Backslash: 92,
49
+ CloseBracket: 93,
50
+ Backtick: 96,
51
+ OpenBrace: 123,
52
+ CloseBrace: 125,
53
+ };
54
+ /** Letters, digits, `_`, `$` and anything outside ASCII, as identifiers and numbers use. */
55
+ function isWordChar(code) {
56
+ return ((code >= 97 && code <= 122) ||
57
+ (code >= 65 && code <= 90) ||
58
+ (code >= 48 && code <= 57) ||
59
+ code === 95 ||
60
+ code === Char.Dollar ||
61
+ code > 127);
62
+ }
63
+ /** Whether the code right before `end` is a word after which a `/` starts a regular expression. */
64
+ function afterKeyword(text, end) {
65
+ let wordEnd = end;
66
+ while (wordEnd > 0 && /\s/.test(text[wordEnd - 1]))
67
+ wordEnd--;
68
+ let wordStart = wordEnd;
69
+ while (wordStart > 0 && isWordChar(text.charCodeAt(wordStart - 1)))
70
+ wordStart--;
71
+ return BEFORE_REGEX.has(text.slice(wordStart, wordEnd));
72
+ }
73
+ /**
74
+ * Whether the slash at `at` ends a JSX tag, as in `</ul>` or `<OrderLeg leg={leg} />`, where a regular expression
75
+ * could otherwise start and run on over the brackets after it.
76
+ */
77
+ function endsJsxTag(text, at) {
78
+ if (text.charCodeAt(at - 1) === Char.LessThan)
79
+ return true;
80
+ if (text.charCodeAt(at + 1) !== Char.GreaterThan)
81
+ return false;
82
+ let before = at - 1;
83
+ while (before > 0 && /\s/.test(text[before]))
84
+ before--;
85
+ return text.charCodeAt(before) === Char.CloseBrace;
86
+ }
87
+ /**
88
+ * The offset of the first bracket nested more than `limit` deep, or null. Parentheses, brackets, braces and template
89
+ * substitutions count; strings, comments, regular expressions and template text are skipped. A closing bracket closes
90
+ * up to the one it matches, so a literal read wrongly unbalances only the brackets around it.
91
+ */
92
+ export function tooDeeplyNested(text, limit) {
93
+ // Open brackets by their character; a substitution is kept as its backtick.
94
+ const open = [];
95
+ const counts = new Uint32Array(128);
96
+ let regexAllowed = true;
97
+ let index = 0;
98
+ const push = (code) => {
99
+ open.push(code);
100
+ counts[code]++;
101
+ return open.length > limit;
102
+ };
103
+ // Reads template text up to its end or a substitution; true when the substitution is one too deep.
104
+ const templateText = () => {
105
+ while (index < text.length) {
106
+ const code = text.charCodeAt(index);
107
+ if (code === Char.Backslash)
108
+ index += 2;
109
+ else if (code === Char.Backtick) {
110
+ index++;
111
+ regexAllowed = false;
112
+ return false;
113
+ }
114
+ else if (code === Char.Dollar && text.charCodeAt(index + 1) === Char.OpenBrace) {
115
+ if (push(Char.Backtick))
116
+ return true;
117
+ index += 2;
118
+ regexAllowed = true;
119
+ return false;
120
+ }
121
+ else
122
+ index++;
123
+ }
124
+ return false;
125
+ };
126
+ // Skips a string or regular expression; neither goes past the end of the line.
127
+ const literal = (end) => {
128
+ let inClass = false;
129
+ while (index < text.length) {
130
+ const code = text.charCodeAt(index);
131
+ if (code === Char.LineFeed)
132
+ return;
133
+ index += code === Char.Backslash ? 2 : 1;
134
+ if (end === Char.Slash && code === Char.OpenBracket)
135
+ inClass = true;
136
+ else if (code === Char.CloseBracket)
137
+ inClass = false;
138
+ else if (code === end && !inClass)
139
+ return;
140
+ }
141
+ };
142
+ while (index < text.length) {
143
+ const code = text.charCodeAt(index);
144
+ const next = text.charCodeAt(index + 1);
145
+ const start = index;
146
+ if (code === Char.Space || code === Char.LineFeed || code === Char.Tab || code === Char.CarriageReturn) {
147
+ index++;
148
+ }
149
+ else if (code === Char.Slash && next === Char.Slash) {
150
+ const end = text.indexOf('\n', index);
151
+ index = end === -1 ? text.length : end;
152
+ }
153
+ else if (code === Char.Slash && next === Char.Star) {
154
+ const end = text.indexOf('*/', index + 2);
155
+ index = end === -1 ? text.length : end + 2;
156
+ }
157
+ else if (code === Char.SingleQuote ||
158
+ code === Char.DoubleQuote ||
159
+ (code === Char.Slash && (regexAllowed || afterKeyword(text, start)) && !endsJsxTag(text, start))) {
160
+ index++;
161
+ literal(code);
162
+ regexAllowed = false;
163
+ }
164
+ else if (code === Char.Backtick) {
165
+ index++;
166
+ if (templateText())
167
+ return index;
168
+ }
169
+ else if (isWordChar(code)) {
170
+ while (index < text.length && isWordChar(text.charCodeAt(index)))
171
+ index++;
172
+ regexAllowed = false;
173
+ }
174
+ else if (code === Char.OpenParen || code === Char.OpenBracket || code === Char.OpenBrace) {
175
+ if (push(code))
176
+ return start;
177
+ index++;
178
+ regexAllowed = true;
179
+ }
180
+ else if (code === Char.CloseParen || code === Char.CloseBracket || code === Char.CloseBrace) {
181
+ index++;
182
+ // A block can end right before a regular expression; a value in parentheses or brackets is divided.
183
+ regexAllowed = code === Char.CloseBrace;
184
+ const opener = code === Char.CloseParen ? Char.OpenParen : code === Char.CloseBracket ? Char.OpenBracket : Char.OpenBrace;
185
+ const closesSubstitution = code === Char.CloseBrace && counts[Char.Backtick] !== 0;
186
+ if (counts[opener] === 0 && !closesSubstitution)
187
+ continue;
188
+ let closed;
189
+ do {
190
+ closed = open.pop();
191
+ counts[closed]--;
192
+ } while (closed !== opener && !(closed === Char.Backtick && code === Char.CloseBrace));
193
+ // A substitution after this one is as deep as the one it closed, so it is never the first too deep.
194
+ if (closed === Char.Backtick)
195
+ templateText();
196
+ }
197
+ else {
198
+ index++;
199
+ regexAllowed = true;
200
+ }
201
+ }
202
+ return null;
203
+ }
10
204
  /** The first parser error, located at its first label, or at the start of the file when it has none. */
11
205
  export function firstError(errors) {
12
206
  const first = errors.at(0);
@@ -18,6 +212,16 @@ export function firstError(errors) {
18
212
  export function parseFile(fileName, code) {
19
213
  // A byte order mark is not part of the code, and editors don't count it as a column.
20
214
  const text = code.charCodeAt(0) === 0xfeff ? code.slice(1) : code;
215
+ const tooDeep = tooDeeplyNested(text, MAX_NESTING);
216
+ if (tooDeep !== null) {
217
+ const message = `Brackets are nested more than ${MAX_NESTING} deep, too deep to parse safely`;
218
+ return {
219
+ source: new SourceText(text),
220
+ program: parseSync(fileName, '', parserOptions(fileName)).program,
221
+ comments: [],
222
+ error: { message, start: tooDeep, end: tooDeep + 1 },
223
+ };
224
+ }
21
225
  const result = parseSync(fileName, text, parserOptions(fileName));
22
226
  return {
23
227
  source: new SourceText(text),