test-inventory 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - `--fields` keeps only the named keys of each test, and `id`, as in `--fields fullName,reason`, so a short question gets a short answer. Output written with it leaves keys out, so it no longer matches the JSON Schema. The library exports the same as `pickTestFields()`.
8
+
9
+ - [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - Filters keep only the tests a question is about: `--state`, `--tag`, `--grep` (a regular expression on the full name, ignoring case), `--only` and `--without-reason`, as in `--state skip --state fixme` for the tests that don't run. The output then describes only those tests: the describes around them, their diagnostics and a summary counted over them. The library exports the same as `filterInventory()`.
10
+
11
+ - [#37](https://github.com/Huldoser/test-inventory/pull/37) [`5aeb139`](https://github.com/Huldoser/test-inventory/commit/5aeb13959daaeb0fdf4488765944fe42ef7754bc) - `--format jsonl` writes the inventory as JSON Lines: an `inventory` line, one line per suite, test and diagnostic, and a `summary` line, each marked with a `type` key. Tools that read one record per line, such as `grep` or DuckDB, no longer need the whole document in memory. The `InventoryLine` type describes the lines.
12
+
13
+ ### Patch Changes
14
+
15
+ - [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - A fixtures file that builds its test object with a function, written there or imported from another file, as in `export const test = defineDeskTest('equities')`, is followed: MSW's test files import such a `test`. Their tests were missing before, with no diagnostic when the fixtures file didn't import the framework itself. A test object built with a call the scan can't follow now gets an `unresolved-test-import` error that names the call, as in "builds it with withWarmup(), which is not followed". A function that returns what another function returns, as `() => onFeed('NYSE')`, is followed too.
16
+
17
+ - [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - A call the runner refuses that is written like a declaration, as `describe.sequential('title', fn)` in Vitest 5, `test('title', fn, { retry: 2 })` from Vitest 4 or `test.only.skip('title', fn)`, is listed as a plain test or describe without the part that fails. Its tests were missing before; now they are `notLoaded`, like every other test in the file.
18
+
19
+ - [#34](https://github.com/Huldoser/test-inventory/pull/34) [`0f96258`](https://github.com/Huldoser/test-inventory/commit/0f96258e65085fa1b7c667b5fa7219a8b19230df) - States follow the runners' precedence more closely, as checked against Vitest 5.0.4 running generated files. A skip in a test or hook body loses to a fixme, skip or todo set while the file loads, as on a describe around the test, since the body never runs. In Vitest, a test's own options win over its chain, so `test.skip('title', { skip: false }, fn)` is active, but the chain still sets its mode: `test.concurrent('title', { concurrent: false }, fn)` runs in parallel. A describe passes its `skip` option down to the tests and describes inside, where it wins over todo, also when the describe's own `.only` keeps the describe itself from being skipped.
20
+
21
+ ## 0.2.1
22
+
23
+ ### Patch Changes
24
+
25
+ - [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - A skip in a `catch` block, such as `context.skip()` when a test server can't start, is conditional, with the `catch` clause as its condition, as in `catch (error)`. It was recorded as an unconditional skip before. A test declared in a `catch` block gets `isInCondition: true`.
26
+
27
+ - [#32](https://github.com/Huldoser/test-inventory/pull/32) [`3fcc933`](https://github.com/Huldoser/test-inventory/commit/3fcc93368dc9a4d810ff52b8d7d5fe4f99ca63ee) - Globs match folders and files whose names start with a dot, as in `.storybook/` or `.github/scripts/`, as they do in Playwright and Vitest; test files there were left out before. `.git` folders are always left out, like `node_modules`.
28
+
29
+ - [#31](https://github.com/Huldoser/test-inventory/pull/31) [`631cc6b`](https://github.com/Huldoser/test-inventory/commit/631cc6b208aca5b28076da6eba33d8e6426ab778) - Workspace packages are found from both `pnpm-workspace.yaml` and the `workspaces` of `package.json`. When a repository had both, as after a move to pnpm that kept the old list, only `package.json` was read, and a package listed only in `pnpm-workspace.yaml` got an `unresolved-test-import` error.
30
+
31
+ - [#30](https://github.com/Huldoser/test-inventory/pull/30) [`968e2bc`](https://github.com/Huldoser/test-inventory/commit/968e2bcd15c2157faae8fe91604634b1c1f68175) - A call through an imported name that reads like a test function is followed, and reported with `unresolved-test-import` when it can't be, also when it goes through a member the scan doesn't know, as in `it.effect('title', fn)` from `@effect/vitest`, or passes a timeout after the body, as in `test('title', fn, 60_000)`. Such tests were missing before, with no diagnostic. A module's own export of a name now wins over the same name passed on by `export *`, as in JavaScript: `export const it = ...` next to `export * from 'vitest'` is no longer read as Vitest's `it`.
32
+
33
+ - [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - Tests on a test object that a function returns are found, as in `conditionalTest({ min: 22 })('title', fn)` or `const quoteTest = onNode(22)`, when the function is declared in the file or imported from a local file. They were missing before, with no diagnostic. When the function returns a different test function on different runs, as `it.skip` or `it`, only the modifiers they all have apply, as for a variable chosen by a condition. When the function can't be followed and its name reads like a test object, as for one from an installed package, the calls on what it returns are read as tests on trust, with a `local-test-object` warning.
34
+
3
35
  ## 0.2.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -70,7 +70,9 @@ the first test's record includes:
70
70
  ```
71
71
 
72
72
  The output also has a `summary` with counts, a record for each describe, and `diagnostics` that point at code the
73
- scan could not read and values it could not work out.
73
+ scan could not read and values it could not work out. Filters such as `--state skip`, `--tag @smoke` and
74
+ `--grep "limit order"` keep only some tests, `--fields fullName,reason` keeps only some keys, and `--format jsonl`
75
+ writes one record per line.
74
76
 
75
77
  ## Library
76
78
 
package/dist/ast.d.ts CHANGED
@@ -4,6 +4,17 @@ export declare function childNodes(node: Node): Node[];
4
4
  /** Strips TypeScript-only wrappers such as `test!`, `test as any` and `<any>test`. */
5
5
  export declare function unwrap(node: Expression): Expression;
6
6
  export declare function isFunctionNode(node: Node | null | undefined): node is FunctionNode;
7
+ /** A value a function returns, with the nodes inside the function around its `return`, outermost first. */
8
+ export interface ReturnValue {
9
+ expression: Expression;
10
+ around: Node[];
11
+ }
12
+ /**
13
+ * The values a function returns; null when it may return no value, as with `return;` or no `return` at all, and for
14
+ * an async function or a generator, which returns a promise or an iterator whatever its `return` says. A `return` in a
15
+ * function inside it is that function's.
16
+ */
17
+ export declare function returnValues(fn: FunctionNode): ReturnValue[] | null;
7
18
  export declare function isStringLiteral(node: Node): node is StringLiteral;
8
19
  /** `import.meta.vitest`, which in-source Vitest tests read their functions from. */
9
20
  export declare function isImportMetaVitest(expression: Expression): boolean;
package/dist/ast.js CHANGED
@@ -32,6 +32,36 @@ export function unwrap(node) {
32
32
  export function isFunctionNode(node) {
33
33
  return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression';
34
34
  }
35
+ /**
36
+ * The values a function returns; null when it may return no value, as with `return;` or no `return` at all, and for
37
+ * an async function or a generator, which returns a promise or an iterator whatever its `return` says. A `return` in a
38
+ * function inside it is that function's.
39
+ */
40
+ export function returnValues(fn) {
41
+ if (fn.async || fn.generator)
42
+ return null;
43
+ // Only declarations of overloads have no body.
44
+ const body = fn.body;
45
+ if (body.type !== 'BlockStatement')
46
+ return [{ expression: body, around: [] }];
47
+ const values = [];
48
+ // Copied only at a `return`, so that a long function costs no more than its size.
49
+ const around = [];
50
+ const visit = (node) => {
51
+ if (node.type === 'ReturnStatement') {
52
+ values.push(node.argument ? { expression: node.argument, around: [...around] } : null);
53
+ return;
54
+ }
55
+ if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
56
+ return;
57
+ around.push(node);
58
+ for (const child of childNodes(node))
59
+ visit(child);
60
+ around.pop();
61
+ };
62
+ visit(body);
63
+ return values.length > 0 && !values.includes(null) ? values : null;
64
+ }
35
65
  export function isStringLiteral(node) {
36
66
  return node.type === 'Literal' && typeof node.value === 'string';
37
67
  }
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,6 @@ export interface Io {
4
4
  stderr: Writable;
5
5
  cwd: string;
6
6
  }
7
- export declare const HELP = "Usage: test-inventory <glob...> --framework <playwright|vitest> [options]\n\nLists the tests in the matching files as JSON, read from the source code without running them.\n\nOptions:\n --framework <name> playwright or vitest (required)\n --root <dir> project root; paths in the output are relative to it (default: current directory)\n --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)\n --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project\n --comments <mode> comments above tests: all, non-active or none (default: non-active)\n -o, --output <file> write to a file instead of standard output\n --pretty indent the JSON\n -h, --help show this help\n -v, --version show the version\n\nExit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be\nwritten, or a crash. 1 is reserved for a future release.\n\nExample:\n test-inventory \"tests/**/*.spec.ts\" --framework playwright --output inventory.json\n";
7
+ export declare const HELP = "Usage: test-inventory <glob...> --framework <playwright|vitest> [options]\n\nLists the tests in the matching files as JSON, read from the source code without running them.\n\nOptions:\n --framework <name> playwright or vitest (required)\n --root <dir> project root; paths in the output are relative to it (default: current directory)\n --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)\n --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project\n --comments <mode> comments above tests: all, non-active or none (default: non-active)\n --state <state> keep tests in this state, such as skip or fixme; can be repeated\n --tag <tag> keep tests with this tag, such as @smoke; can be repeated\n --grep <regex> keep tests whose full name matches, ignoring case\n --only keep tests that use .only, or are inside a describe that does\n --without-reason keep tests that don't run and give no reason\n --fields <keys> write only these keys of each test, separated by commas; id is always kept\n --format <format> json or jsonl, one record per line (default: json)\n -o, --output <file> write to a file instead of standard output\n --pretty indent the JSON\n -h, --help show this help\n -v, --version show the version\n\nExit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be\nwritten, or a crash. 1 is reserved for a future release.\n\nExamples:\n test-inventory \"tests/**/*.spec.ts\" --framework playwright --output inventory.json\n test-inventory \"tests/**/*.spec.ts\" --framework playwright --state skip --fields fullName,reason\n";
8
8
  /** Runs the command line with the given arguments and streams. Resolves to the exit code. */
9
9
  export declare function run(argv: string[], io: Io): Promise<number>;
package/dist/cli.js CHANGED
@@ -3,7 +3,8 @@ import { once } from 'node:events';
3
3
  import path from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { readFailure } from "./files.js";
6
- import { writeInventory } from "./json.js";
6
+ import { checkFields, filterInventory, matcher, pickTestFields } from "./filter.js";
7
+ import { writeInventory, writeInventoryLines } from "./json.js";
7
8
  import { OptionsError } from "./options.js";
8
9
  import { scan } from "./scan.js";
9
10
  import { toolVersion } from "./version.js";
@@ -17,6 +18,13 @@ Options:
17
18
  --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)
18
19
  --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project
19
20
  --comments <mode> comments above tests: all, non-active or none (default: non-active)
21
+ --state <state> keep tests in this state, such as skip or fixme; can be repeated
22
+ --tag <tag> keep tests with this tag, such as @smoke; can be repeated
23
+ --grep <regex> keep tests whose full name matches, ignoring case
24
+ --only keep tests that use .only, or are inside a describe that does
25
+ --without-reason keep tests that don't run and give no reason
26
+ --fields <keys> write only these keys of each test, separated by commas; id is always kept
27
+ --format <format> json or jsonl, one record per line (default: json)
20
28
  -o, --output <file> write to a file instead of standard output
21
29
  --pretty indent the JSON
22
30
  -h, --help show this help
@@ -25,18 +33,24 @@ Options:
25
33
  Exit codes: 0 when the scan finishes, even with diagnostics; 2 for invalid arguments, an output that cannot be
26
34
  written, or a crash. 1 is reserved for a future release.
27
35
 
28
- Example:
36
+ Examples:
29
37
  test-inventory "tests/**/*.spec.ts" --framework playwright --output inventory.json
38
+ test-inventory "tests/**/*.spec.ts" --framework playwright --state skip --fields fullName,reason
30
39
  `;
31
- /** CLI names for the option names in `scan()`'s errors. */
40
+ /** CLI names for the option names in the library's errors. */
32
41
  const FLAGS = {
33
- patterns: 'the glob patterns',
34
- framework: '--framework',
35
- root: '--root',
36
- ignore: '--ignore',
37
- frameworkVersion: '--framework-version',
38
- comments: '--comments',
42
+ 'options.patterns': 'the glob patterns',
43
+ 'options.framework': '--framework',
44
+ 'options.root': '--root',
45
+ 'options.ignore': '--ignore',
46
+ 'options.frameworkVersion': '--framework-version',
47
+ 'options.comments': '--comments',
48
+ 'filter.states': '--state',
49
+ 'filter.tags': '--tag',
50
+ 'filter.grep': '--grep',
51
+ fields: '--fields',
39
52
  };
53
+ const FORMATS = ['json', 'jsonl'];
40
54
  async function writeTo(stream, chunk) {
41
55
  if (stream.errored)
42
56
  throw stream.errored;
@@ -89,6 +103,13 @@ export async function run(argv, io) {
89
103
  ignore: { type: 'string', multiple: true },
90
104
  'framework-version': { type: 'string' },
91
105
  comments: { type: 'string' },
106
+ state: { type: 'string', multiple: true },
107
+ tag: { type: 'string', multiple: true },
108
+ grep: { type: 'string' },
109
+ only: { type: 'boolean' },
110
+ 'without-reason': { type: 'boolean' },
111
+ fields: { type: 'string' },
112
+ format: { type: 'string' },
92
113
  output: { type: 'string', short: 'o' },
93
114
  pretty: { type: 'boolean' },
94
115
  help: { type: 'boolean', short: 'h' },
@@ -112,8 +133,26 @@ export async function run(argv, io) {
112
133
  return fail('give at least one glob pattern for the test files.');
113
134
  if (values.framework === undefined)
114
135
  return fail('--framework is required: playwright or vitest.');
136
+ const format = values.format ?? 'json';
137
+ if (!FORMATS.includes(format))
138
+ return fail('--format must be json or jsonl.');
139
+ if (format === 'jsonl' && values.pretty)
140
+ return fail('--pretty indents JSON, so it does not go with --format jsonl.');
141
+ const filter = {
142
+ states: values.state,
143
+ tags: values.tag,
144
+ grep: values.grep,
145
+ only: values.only,
146
+ withoutReason: values['without-reason'],
147
+ };
148
+ const fields = values.fields?.split(',').map((field) => field.trim());
115
149
  let inventory;
150
+ let result;
116
151
  try {
152
+ // Checked before the scan, so a mistyped state or key doesn't wait for every file to be read.
153
+ matcher(filter);
154
+ if (fields !== undefined)
155
+ checkFields(fields);
117
156
  inventory = await scan({
118
157
  patterns: positionals,
119
158
  framework: values.framework,
@@ -122,10 +161,12 @@ export async function run(argv, io) {
122
161
  frameworkVersion: values['framework-version'],
123
162
  comments: values.comments,
124
163
  });
164
+ const filtered = filterInventory(inventory, filter);
165
+ result = fields === undefined ? filtered : pickTestFields(filtered, fields);
125
166
  }
126
167
  catch (error) {
127
168
  if (error instanceof OptionsError) {
128
- return fail(error.message.replace(/^options\.(\w+)/, (_, name) => FLAGS[name]));
169
+ return fail(error.message.replace(/^[\w.]+/, (name) => FLAGS[name]));
129
170
  }
130
171
  io.stderr.write(`test-inventory: unexpected error: ${error instanceof Error ? String(error.stack) : String(error)}\n`);
131
172
  return FAILED;
@@ -139,8 +180,14 @@ export async function run(argv, io) {
139
180
  io.stdout.on('error', quiet);
140
181
  try {
141
182
  await writeOutput(io, values.output, async (output) => {
142
- await writeInventory(inventory, values.pretty === true, (chunk) => writeTo(output, chunk));
143
- await writeTo(output, '\n');
183
+ const write = (chunk) => writeTo(output, chunk);
184
+ if (format === 'jsonl') {
185
+ await writeInventoryLines(result, write);
186
+ }
187
+ else {
188
+ await writeInventory(result, values.pretty === true, write);
189
+ await write('\n');
190
+ }
144
191
  });
145
192
  }
146
193
  catch (error) {
package/dist/extract.js CHANGED
@@ -1,4 +1,4 @@
1
- import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
1
+ import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
2
2
  import { exportName } from "./frameworks/bindings.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
@@ -80,6 +80,8 @@ class Walker {
80
80
  callsWalked = 0;
81
81
  /** Problems already reported, since a function walked at several calls finds the same ones each time. */
82
82
  reported = new Set();
83
+ /** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
84
+ returns = new Map();
83
85
  /** For each file's program, the scopes around each function in it. */
84
86
  scopeChains = new Map();
85
87
  constructor(main, openModule) {
@@ -178,6 +180,14 @@ class Walker {
178
180
  this.visit(node.right, this.guarded(context, guard));
179
181
  return;
180
182
  }
183
+ case 'TryStatement':
184
+ this.visit(node.block, context);
185
+ // A `catch` block runs only when the `try` block throws, as when a test server can't start.
186
+ if (node.handler)
187
+ this.visit(node.handler, this.guarded(context, this.catchCondition(node.handler)));
188
+ if (node.finalizer)
189
+ this.visit(node.finalizer, context);
190
+ return;
181
191
  case 'SwitchStatement': {
182
192
  const discriminant = this.text(node.discriminant);
183
193
  const values = node.cases.flatMap((switchCase) => (switchCase.test ? [this.text(switchCase.test)] : []));
@@ -241,6 +251,10 @@ class Walker {
241
251
  inBody: context.test !== null || context.hookScope !== null,
242
252
  };
243
253
  }
254
+ /** A `catch` block's condition, written as its clause: `catch` or `catch (error)`. */
255
+ catchCondition(handler) {
256
+ return this.text({ start: handler.start, end: handler.body.start }).trim();
257
+ }
244
258
  guarded(context, guard) {
245
259
  return { ...context, isInCondition: true, guards: guard === null ? context.guards : [...context.guards, guard] };
246
260
  }
@@ -280,6 +294,8 @@ class Walker {
280
294
  testContext: context.testContext,
281
295
  inTest: context.test !== null,
282
296
  inBody: context.test !== null || context.hookScope !== null,
297
+ passedDown: context.scope.suite?.declaration.passesDown ?? null,
298
+ returnsOf: (name, scopes) => this.returnsOf(name, scopes),
283
299
  });
284
300
  if (!api)
285
301
  return false;
@@ -308,6 +324,7 @@ class Walker {
308
324
  reason: api.reason,
309
325
  hasDynamicReason: api.hasDynamicReason,
310
326
  line: this.lineOf(call),
327
+ afterLoad: context.test !== null || context.hookScope !== null,
311
328
  };
312
329
  if (context.helper)
313
330
  this.effectsOf(context.helper).push({ kind: 'state', setting, call });
@@ -345,7 +362,7 @@ class Walker {
345
362
  case 'invalid': {
346
363
  const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
347
364
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
348
- return true;
365
+ return api.declaration ? this.handle(call, api.declaration, context) : true;
349
366
  }
350
367
  case 'other':
351
368
  return false;
@@ -379,7 +396,7 @@ class Walker {
379
396
  const fn = this.functionNamed(callee.name, this.scopes);
380
397
  if (!fn) {
381
398
  const runsHere = !context.test && !context.hookScope && !context.helper;
382
- const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name) : null;
399
+ const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name, this.scopes) : null;
383
400
  return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
384
401
  }
385
402
  const sites = this.callSites.get(fn) ?? new Map();
@@ -414,14 +431,41 @@ class Walker {
414
431
  }
415
432
  return null;
416
433
  }
434
+ /**
435
+ * What a function declared in a file, the one being walked unless said otherwise, or imported from a local module,
436
+ * returns; null otherwise. Each declaration is looked up once, however many calls use it.
437
+ */
438
+ returnsOf(name, scopes, frame = this.frame) {
439
+ const declaration = frame.values.declarationOf(name, scopes);
440
+ if (declaration === null)
441
+ return null;
442
+ if (!this.returns.has(declaration)) {
443
+ const local = this.functionIn(frame, name, scopes);
444
+ const callee = local ? { fn: local, frame } : this.importedFunction(name, scopes, frame);
445
+ this.returns.set(declaration, callee && this.returnsIn(callee));
446
+ }
447
+ return this.returns.get(declaration) ?? null;
448
+ }
449
+ returnsIn({ fn, frame }) {
450
+ const scopes = [...this.scopeChain(fn, frame), fn];
451
+ const values = returnValues(fn)?.map(({ expression, around }) => ({
452
+ expression,
453
+ scopes: [...scopes, ...around.filter(isScopeNode)],
454
+ }));
455
+ return {
456
+ adapter: frame.adapter,
457
+ values: values ?? null,
458
+ returnsOf: (name, at) => this.returnsOf(name, at, frame),
459
+ };
460
+ }
417
461
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
418
- importedFunction(name) {
419
- const declaration = this.frame.values.declarationOf(name, this.scopes);
420
- const source = this.frame.values.importSource(name, this.scopes);
462
+ importedFunction(name, scopes, from = this.frame) {
463
+ const declaration = from.values.declarationOf(name, scopes);
464
+ const source = from.values.importSource(name, scopes);
421
465
  if (source === null)
422
466
  return null;
423
467
  const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
424
- const frame = this.openFrom(this.frame, source);
468
+ const frame = this.openFrom(from, source);
425
469
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
426
470
  }
427
471
  /** A module a file imports, read as a frame once; null when it can't be read or parsed. */
@@ -540,8 +584,8 @@ class Walker {
540
584
  this.parents.splice(0, this.parents.length, ...savedParents);
541
585
  }
542
586
  /** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
543
- scopeChain(fn) {
544
- const { program } = this.frame.parsed;
587
+ scopeChain(fn, frame = this.frame) {
588
+ const { program } = frame.parsed;
545
589
  let chains = this.scopeChains.get(program);
546
590
  if (!chains) {
547
591
  const found = new Map();
@@ -645,7 +689,8 @@ class Walker {
645
689
  if (effect.kind === 'configure')
646
690
  return [effect];
647
691
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
648
- return [{ ...effect, setting: { ...effect.setting, condition } }];
692
+ // Helpers are expanded here only for calls from tests and hooks.
693
+ return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
649
694
  });
650
695
  };
651
696
  for (const call of this.helperCalls) {
package/dist/files.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface FoundFile {
12
12
  * left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
13
13
  * when it matches too.
14
14
  */
15
- export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework): Promise<{
15
+ export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework, platform?: NodeJS.Platform): Promise<{
16
16
  files: FoundFile[];
17
17
  diagnostics: Diagnostic[];
18
18
  }>;
package/dist/files.js CHANGED
@@ -43,12 +43,14 @@ function recordingFs(failures) {
43
43
  * left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
44
44
  * when it matches too.
45
45
  */
46
- export async function findFiles(root, patterns, ignore, framework) {
46
+ export async function findFiles(root, patterns, ignore, framework, platform = process.platform) {
47
47
  const failures = [];
48
48
  // Only files match; a folder named like a test file, such as a screenshot folder `checkout.spec.ts/`, is not expanded.
49
- const matches = await glob(patterns.map((pattern) => normalizePattern(pattern)), {
49
+ // As in both runners, names that start with a dot match too, as in `.storybook/`.
50
+ const matches = await glob(patterns.map((pattern) => normalizePattern(pattern, platform)), {
50
51
  cwd: root,
51
- ignore: ['**/node_modules/**', ...ignore.map((pattern) => normalizePattern(pattern))],
52
+ dot: true,
53
+ ignore: ['**/node_modules/**', '**/.git/**', ...ignore.map((pattern) => normalizePattern(pattern, platform))],
52
54
  onlyFiles: true,
53
55
  expandDirectories: false,
54
56
  fs: recordingFs(failures),
@@ -0,0 +1,37 @@
1
+ import type { Inventory, TestRecord, TestState } from './types.ts';
2
+ /** @inline */
3
+ export interface InventoryFilter {
4
+ /** Keeps tests in one of these states. */
5
+ states?: TestState[];
6
+ /** Keeps tests with one of these tags, as the output writes them. */
7
+ tags?: string[];
8
+ /** Keeps tests whose `fullName` matches this regular expression, ignoring case, as Playwright's `--grep` does. */
9
+ grep?: string;
10
+ /** Keeps tests that use `.only`, or are inside a describe that does. */
11
+ only?: boolean;
12
+ /** Keeps tests that don't run and give no reason. Tests in a file the runner refuses are left out. */
13
+ withoutReason?: boolean;
14
+ }
15
+ /** An inventory whose test records have only `id` and the keys picked with `pickTestFields()`. */
16
+ export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests'> & {
17
+ tests: Pick<TestRecord, 'id' | K>[];
18
+ };
19
+ /** Checks a filter and returns whether a test passes it. */
20
+ export declare function matcher(filter: InventoryFilter): (test: TestRecord) => boolean;
21
+ /**
22
+ * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
23
+ * result describes only those tests: the describes around them, their diagnostics, and a summary counted over them.
24
+ * `summary.fileCount` stays the number of files scanned.
25
+ *
26
+ * Throws a `TypeError` for an invalid filter.
27
+ */
28
+ export declare function filterInventory(inventory: Inventory, filter: InventoryFilter): Inventory;
29
+ /** Checks the keys to pick and returns them after `id`, each once. */
30
+ export declare function checkFields(fields: unknown): (keyof TestRecord)[];
31
+ /**
32
+ * Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
33
+ * diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
34
+ *
35
+ * Throws a `TypeError` for a key that test records don't have.
36
+ */
37
+ export declare function pickTestFields<K extends keyof TestRecord>(inventory: Inventory, fields: readonly K[]): PickedInventory<K>;
package/dist/filter.js ADDED
@@ -0,0 +1,153 @@
1
+ import { checkObject, OptionsError } from "./options.js";
2
+ import { summarize } from "./summary.js";
3
+ const STATES = ['active', 'skip', 'fixme', 'todo', 'expectedToFail', 'notLoaded'];
4
+ const FILTER_KEYS = [
5
+ 'states',
6
+ 'tags',
7
+ 'grep',
8
+ 'only',
9
+ 'withoutReason',
10
+ ];
11
+ // Typed as a record of every key, so a key added to TestRecord fails to compile until it is listed here.
12
+ const TEST_KEYS = Object.keys({
13
+ id: true,
14
+ bodyHash: true,
15
+ suiteId: true,
16
+ suitePath: true,
17
+ relativeFilePath: true,
18
+ fileName: true,
19
+ lineStart: true,
20
+ lineEnd: true,
21
+ columnStart: true,
22
+ columnEnd: true,
23
+ declaredAt: true,
24
+ title: true,
25
+ titleWithoutTags: true,
26
+ hasDynamicTitle: true,
27
+ fullName: true,
28
+ isTypeTest: true,
29
+ state: true,
30
+ isActive: true,
31
+ isSkipped: true,
32
+ isFixme: true,
33
+ isTodo: true,
34
+ isExpectedToFail: true,
35
+ isNotLoaded: true,
36
+ isOnly: true,
37
+ isConditional: true,
38
+ condition: true,
39
+ reason: true,
40
+ hasDynamicReason: true,
41
+ stateSource: true,
42
+ stateLine: true,
43
+ isParallel: true,
44
+ isSerial: true,
45
+ tags: true,
46
+ annotations: true,
47
+ comments: true,
48
+ locks: true,
49
+ meta: true,
50
+ isParameterized: true,
51
+ caseCount: true,
52
+ isInLoop: true,
53
+ isInCondition: true,
54
+ isInFunction: true,
55
+ });
56
+ function list(value, option, allowed) {
57
+ if (value === undefined)
58
+ return [];
59
+ const valid = (item) => typeof item === 'string' && item !== '' && (allowed === null || allowed.includes(item));
60
+ if (Array.isArray(value) && value.every(valid))
61
+ return value;
62
+ const bad = Array.isArray(value) ? value.find((item) => !valid(item)) : value;
63
+ const expected = allowed === null ? 'non-empty strings' : allowed.map((item) => `"${item}"`).join(', ');
64
+ throw new OptionsError(`${option} can only hold ${expected}; got ${JSON.stringify(bad)}.`);
65
+ }
66
+ function flag(value, option) {
67
+ if (value === undefined)
68
+ return false;
69
+ if (typeof value !== 'boolean')
70
+ throw new OptionsError(`${option} must be true or false.`);
71
+ return value;
72
+ }
73
+ function pattern(value) {
74
+ if (value === undefined)
75
+ return null;
76
+ if (typeof value !== 'string' || value === '')
77
+ throw new OptionsError('filter.grep must be a non-empty string.');
78
+ try {
79
+ return new RegExp(value, 'i');
80
+ }
81
+ catch (error) {
82
+ throw new OptionsError(`filter.grep must be a regular expression: ${error.message}.`);
83
+ }
84
+ }
85
+ /** Checks a filter and returns whether a test passes it. */
86
+ export function matcher(filter) {
87
+ const checked = checkObject(filter, 'filter');
88
+ // A misspelled key would return every test, an answer that looks filtered but isn't.
89
+ const unknown = Object.keys(checked).find((key) => !FILTER_KEYS.includes(key));
90
+ if (unknown !== undefined) {
91
+ throw new OptionsError(`filter can only have the keys ${FILTER_KEYS.join(', ')}; got "${unknown}".`);
92
+ }
93
+ const states = list(checked.states, 'filter.states', STATES);
94
+ const tags = list(checked.tags, 'filter.tags', null);
95
+ const grep = pattern(checked.grep);
96
+ const only = flag(checked.only, 'filter.only');
97
+ const withoutReason = flag(checked.withoutReason, 'filter.withoutReason');
98
+ return (test) => (states.length === 0 || states.includes(test.state)) &&
99
+ (tags.length === 0 || test.tags.some((tag) => tags.includes(tag))) &&
100
+ (grep === null || grep.test(test.fullName)) &&
101
+ (!only || test.isOnly) &&
102
+ (!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null));
103
+ }
104
+ /**
105
+ * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
106
+ * result describes only those tests: the describes around them, their diagnostics, and a summary counted over them.
107
+ * `summary.fileCount` stays the number of files scanned.
108
+ *
109
+ * Throws a `TypeError` for an invalid filter.
110
+ */
111
+ export function filterInventory(inventory, filter) {
112
+ const tests = inventory.tests.filter(matcher(filter));
113
+ const testIds = new Set(tests.map((test) => test.id));
114
+ const parents = new Map(inventory.suites.map((suite) => [suite.id, suite.parentId]));
115
+ const suiteIds = new Set();
116
+ for (const test of tests) {
117
+ for (let id = test.suiteId; id !== null && !suiteIds.has(id); id = parents.get(id)) {
118
+ suiteIds.add(id);
119
+ }
120
+ }
121
+ const suites = inventory.suites.filter((suite) => suiteIds.has(suite.id));
122
+ const filesWithTests = new Set(inventory.tests.map((test) => test.relativeFilePath));
123
+ const filesWithMatches = new Set(tests.map((test) => test.relativeFilePath));
124
+ // A problem that is not about one test stays, unless every test of its file was left out: an error in a file
125
+ // with no tests, or in package.json, may mean tests are missing from the answer.
126
+ const diagnostics = inventory.diagnostics.filter((diagnostic) => diagnostic.testId === null
127
+ ? !filesWithTests.has(diagnostic.relativeFilePath) || filesWithMatches.has(diagnostic.relativeFilePath)
128
+ : testIds.has(diagnostic.testId));
129
+ const summary = summarize(inventory.summary.fileCount, suites, tests, diagnostics);
130
+ return { ...inventory, summary, suites, tests, diagnostics };
131
+ }
132
+ /** Checks the keys to pick and returns them after `id`, each once. */
133
+ export function checkFields(fields) {
134
+ if (!Array.isArray(fields) || fields.length === 0) {
135
+ throw new OptionsError('fields must be a non-empty array of test record keys.');
136
+ }
137
+ const bad = fields.findIndex((field) => !TEST_KEYS.includes(field));
138
+ if (bad !== -1) {
139
+ throw new OptionsError(`fields can only hold keys of a test record, such as "fullName"; got ${JSON.stringify(fields[bad])}.`);
140
+ }
141
+ return [...new Set(['id', ...fields])];
142
+ }
143
+ /**
144
+ * Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
145
+ * diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
146
+ *
147
+ * Throws a `TypeError` for a key that test records don't have.
148
+ */
149
+ export function pickTestFields(inventory, fields) {
150
+ const keys = checkFields(fields);
151
+ const tests = inventory.tests.map((test) => Object.fromEntries(keys.map((key) => [key, test[key]])));
152
+ return { ...inventory, tests };
153
+ }