test-inventory 0.2.1 → 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,23 @@
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
+
3
21
  ## 0.2.1
4
22
 
5
23
  ### Patch 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";
@@ -294,6 +294,7 @@ class Walker {
294
294
  testContext: context.testContext,
295
295
  inTest: context.test !== null,
296
296
  inBody: context.test !== null || context.hookScope !== null,
297
+ passedDown: context.scope.suite?.declaration.passesDown ?? null,
297
298
  returnsOf: (name, scopes) => this.returnsOf(name, scopes),
298
299
  });
299
300
  if (!api)
@@ -323,6 +324,7 @@ class Walker {
323
324
  reason: api.reason,
324
325
  hasDynamicReason: api.hasDynamicReason,
325
326
  line: this.lineOf(call),
327
+ afterLoad: context.test !== null || context.hookScope !== null,
326
328
  };
327
329
  if (context.helper)
328
330
  this.effectsOf(context.helper).push({ kind: 'state', setting, call });
@@ -360,7 +362,7 @@ class Walker {
360
362
  case 'invalid': {
361
363
  const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
362
364
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
363
- return true;
365
+ return api.declaration ? this.handle(call, api.declaration, context) : true;
364
366
  }
365
367
  case 'other':
366
368
  return false;
@@ -430,33 +432,40 @@ class Walker {
430
432
  return null;
431
433
  }
432
434
  /**
433
- * What a function declared in the file being walked, or imported from a local module, returns; null otherwise. Each
434
- * declaration is looked up once, however many calls use it.
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.
435
437
  */
436
- returnsOf(name, scopes) {
437
- const declaration = this.frame.values.declarationOf(name, scopes);
438
+ returnsOf(name, scopes, frame = this.frame) {
439
+ const declaration = frame.values.declarationOf(name, scopes);
438
440
  if (declaration === null)
439
441
  return null;
440
442
  if (!this.returns.has(declaration)) {
441
- const local = this.functionNamed(name, scopes);
442
- const callee = local ? { fn: local, frame: this.frame } : this.importedFunction(name, scopes);
443
+ const local = this.functionIn(frame, name, scopes);
444
+ const callee = local ? { fn: local, frame } : this.importedFunction(name, scopes, frame);
443
445
  this.returns.set(declaration, callee && this.returnsIn(callee));
444
446
  }
445
447
  return this.returns.get(declaration) ?? null;
446
448
  }
447
449
  returnsIn({ fn, frame }) {
448
- // An async function returns a promise, and a generator an iterator, whatever their `return` says.
449
- const values = fn.async || fn.generator ? null : returnValues(fn, [...this.scopeChain(fn, frame), fn]);
450
- return { adapter: frame.adapter, values };
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
+ };
451
460
  }
452
461
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
453
- importedFunction(name, scopes) {
454
- const declaration = this.frame.values.declarationOf(name, scopes);
455
- const source = this.frame.values.importSource(name, scopes);
462
+ importedFunction(name, scopes, from = this.frame) {
463
+ const declaration = from.values.declarationOf(name, scopes);
464
+ const source = from.values.importSource(name, scopes);
456
465
  if (source === null)
457
466
  return null;
458
467
  const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
459
- const frame = this.openFrom(this.frame, source);
468
+ const frame = this.openFrom(from, source);
460
469
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
461
470
  }
462
471
  /** A module a file imports, read as a frame once; null when it can't be read or parsed. */
@@ -680,7 +689,8 @@ class Walker {
680
689
  if (effect.kind === 'configure')
681
690
  return [effect];
682
691
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
683
- 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 } }];
684
694
  });
685
695
  };
686
696
  for (const call of this.helperCalls) {
@@ -843,31 +853,6 @@ class Walker {
843
853
  return lines;
844
854
  }
845
855
  }
846
- /**
847
- * The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
848
- * with `return;` or no `return` at all. A `return` in a function inside it is that function's.
849
- */
850
- function returnValues(fn, scopes) {
851
- // Only declarations of overloads have no body.
852
- const body = fn.body;
853
- if (body.type !== 'BlockStatement')
854
- return [{ expression: body, scopes }];
855
- const values = [];
856
- const visit = (node, around) => {
857
- if (node.type === 'ReturnStatement') {
858
- values.push(node.argument ? { expression: node.argument, scopes: around } : null);
859
- return;
860
- }
861
- if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
862
- return;
863
- const inner = isScopeNode(node) ? [...around, node] : around;
864
- for (const child of childNodes(node))
865
- visit(child, inner);
866
- };
867
- visit(body, scopes);
868
- const always = values.every((value) => value !== null);
869
- return always && values.length > 0 ? values : null;
870
- }
871
856
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
872
857
  export function extract(main, openModule) {
873
858
  return new Walker(main, openModule).run();
@@ -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
+ }
@@ -9,12 +9,43 @@ export interface ModuleImport {
9
9
  node: Node;
10
10
  }
11
11
  export declare function exportName(name: ModuleExportName): string;
12
+ /** What a module is followed for: a test object, or a function that returns one, as in `defineBrokerTest()`. */
13
+ export type Exported = 'test object' | 'function';
12
14
  /** Names a module's test objects are known by, used to check what a fixtures file exports. */
13
15
  export interface TestObjects {
14
16
  isTestObject(expression: Expression): boolean;
15
17
  isTestName(name: string): boolean;
16
- /** Whether re-exporting `imported` from `source` exports a test object. */
17
- reexports(source: string, imported: string): boolean;
18
+ /** Whether a function the module declares or imports under `name` returns a test object. */
19
+ returnsTestObject(name: string): boolean;
20
+ /** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
21
+ reexports(source: string, imported: string, exported: Exported): boolean;
22
+ }
23
+ /**
24
+ * The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
25
+ * returns is one, as `return base.extend({...})` is. An imported function is followed to its module. The function a
26
+ * module exports as its default, named or not, is also known as `default`.
27
+ */
28
+ export declare class ModuleFunctions {
29
+ private readonly functions;
30
+ /** The functions being read, to end a function that returns what it returns itself. */
31
+ private readonly reading;
32
+ /** Whether each function returns a test object, read once however many calls and functions lead to it. */
33
+ private readonly read;
34
+ /** The values of variables the bindings left unbound, by name, which a call may make test objects. */
35
+ private readonly values;
36
+ /** Whether each of those holds a test object; null while that is being worked out, which ends a loop. */
37
+ private readonly built;
38
+ private readonly imported;
39
+ private readonly follow;
40
+ constructor(program: Program, imported: Map<string, ModuleImport>, follow: FollowImport);
41
+ /** Keeps the variables the bindings didn't bind without following calls, to be worked out when asked. */
42
+ keep(declarators: Iterable<VariableDeclarator>): void;
43
+ /**
44
+ * Whether a variable holds a test object built by a call, as `const test = defineBrokerTest()`. It is worked out
45
+ * only when asked, as following the call reads other modules.
46
+ */
47
+ holdsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
48
+ returnsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
18
49
  }
19
50
  export declare function topLevelDeclarators(program: Program): VariableDeclarator[];
20
51
  /** All variable declarators in the file, at any depth, such as `const { it } = import.meta.vitest` inside an `if`. */
@@ -25,12 +56,12 @@ export declare function moduleImports(program: Program, isFramework: (source: st
25
56
  export declare function requiredModule(expression: Expression): string | null;
26
57
  /** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
27
58
  export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
28
- /** Whether a module exports `name` as a test object. */
29
- export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
59
+ /** Whether a module exports `name` as a test object, or as a function that returns one. */
60
+ export declare function exportsTestObject(program: Program, name: string, objects: TestObjects, exported?: Exported): boolean;
30
61
  /** Whether a module imports, re-exports or requires any of the given sources. */
31
62
  export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
32
63
  /** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
33
- export type FollowImport = (source: string, imported: string) => boolean;
64
+ export type FollowImport = (source: string, imported: string, exported?: Exported) => boolean;
34
65
  /** How a framework recognizes the modules and test objects of a followed file. */
35
66
  export interface Framework {
36
67
  name: string;
@@ -46,7 +77,7 @@ export declare function parseModule(module: ModuleSource): ParsedFile;
46
77
  * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
47
78
  * however many imports lead to it, as fixtures files that build on several others share their bases.
48
79
  */
49
- export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
80
+ export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, exported?: Exported, path?: readonly string[], followed?: Map<string, Followed>): Followed;
50
81
  interface Followed {
51
82
  exported: boolean;
52
83
  reason: string | null;