test-inventory 0.1.1 → 0.1.2

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,13 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#13](https://github.com/Huldoser/test-inventory/pull/13) [`89a029f`](https://github.com/Huldoser/test-inventory/commit/89a029f9e45c2aaa57b5f907717bb8f00829e335) - Locks set with `test.describe.configure({ lock })`, new in Playwright 1.64, are added to every test in the file or describe where the call is made. They were dropped before.
8
+
9
+ - [#13](https://github.com/Huldoser/test-inventory/pull/13) [`89a029f`](https://github.com/Huldoser/test-inventory/commit/89a029f9e45c2aaa57b5f907717bb8f00829e335) - From Vitest 5, a file that calls `vi.mock`, `vi.unmock` or `vi.hoisted` anywhere but its top level gets a `removed-api` error at the call, and its tests are `notLoaded`, as Vitest refuses the file. Files with in-source tests are not checked, as in Vitest.
10
+
3
11
  ## 0.1.1
4
12
 
5
13
  ### Patch Changes
package/dist/extract.js CHANGED
@@ -12,6 +12,12 @@ function calledWhereWritten(call, written) {
12
12
  !call.inBody);
13
13
  }
14
14
  const LOOP_METHODS = new Set(['forEach', 'map']);
15
+ /** What `test.describe.configure()` sets on the describe or file it is called in: the last mode wins, locks add up. */
16
+ function configure(scope, settings) {
17
+ if (settings.mode)
18
+ scope.mode = settings.mode;
19
+ scope.details.locks.push(...settings.locks);
20
+ }
15
21
  /** Joins conditions with `&&`, adding parentheses where a part could otherwise be read differently. */
16
22
  function joinConditions(parts) {
17
23
  if (parts.length <= 1)
@@ -251,11 +257,14 @@ class Walker {
251
257
  (context.test ?? context.hookScope ?? context.scope).states.push(setting);
252
258
  return true;
253
259
  }
254
- case 'mode':
255
- if (context.helper)
256
- this.effectsOf(context.helper).push({ kind: 'mode', mode: api.mode, call });
257
- else if (!context.test && !context.hookScope)
258
- context.scope.mode = api.mode;
260
+ case 'configure':
261
+ this.problems.push(...api.problems);
262
+ if (context.helper) {
263
+ this.effectsOf(context.helper).push({ kind: 'configure', mode: api.mode, locks: api.locks, call });
264
+ }
265
+ else if (!context.test && !context.hookScope) {
266
+ configure(context.scope, api);
267
+ }
259
268
  return true;
260
269
  case 'hook':
261
270
  if (context.test)
@@ -355,7 +364,7 @@ class Walker {
355
364
  return (this.effects.get(fn) ?? []).flatMap((effect) => {
356
365
  if (effect.kind === 'call')
357
366
  return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
358
- if (effect.kind === 'mode')
367
+ if (effect.kind === 'configure')
359
368
  return [effect];
360
369
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
361
370
  return [{ ...effect, setting: { ...effect.setting, condition } }];
@@ -366,7 +375,7 @@ class Walker {
366
375
  if (effect.kind === 'state')
367
376
  call.target.states.push(effect.setting);
368
377
  else if (call.scope)
369
- call.scope.mode = effect.mode;
378
+ configure(call.scope, effect);
370
379
  }
371
380
  }
372
381
  for (const [fn, effects] of this.effects) {
@@ -429,17 +429,31 @@ export class PlaywrightAdapter {
429
429
  }
430
430
  configure(call, context) {
431
431
  const options = call.arguments[0] ? readOptions(this.file, context.scopes, call.arguments[0]) : null;
432
- const mode = options?.values.get('mode');
433
- if (mode?.resolved.ok && ['parallel', 'serial', 'default'].includes(mode.resolved.value)) {
434
- return { kind: 'mode', mode: mode.resolved.value };
432
+ const option = options?.values.get('mode');
433
+ const mode = option?.resolved.ok && ['parallel', 'serial', 'default'].includes(option.resolved.value)
434
+ ? option.resolved.value
435
+ : null;
436
+ const problems = mode
437
+ ? []
438
+ : [
439
+ ...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
440
+ ...(option && !option.resolved.ok
441
+ ? [unresolvedOption(this.file, 'mode', option.node, option.resolved.importedFrom, 'the mode is unknown')]
442
+ : []),
443
+ ];
444
+ const locks = [];
445
+ const lock = options?.values.get('lock');
446
+ if (lock && atLeast(this.file.version, 1, 64)) {
447
+ const strings = readStrings(this.file, context.scopes, lock);
448
+ locks.push(...strings.values);
449
+ for (const { node: unresolved, importedFrom } of strings.unresolved) {
450
+ problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
451
+ }
452
+ }
453
+ if (!mode && locks.length === 0) {
454
+ return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
435
455
  }
436
- const problems = [
437
- ...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
438
- ...(mode && !mode.resolved.ok
439
- ? [unresolvedOption(this.file, 'mode', mode.node, mode.resolved.importedFrom, 'the mode is unknown')]
440
- : []),
441
- ];
442
- return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
456
+ return { kind: 'configure', mode, locks, problems };
443
457
  }
444
458
  /** `test.info().annotations.push(...)` inside a test body. */
445
459
  annotationsPush(call, links, context) {
@@ -1,4 +1,4 @@
1
- import { isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap } from "../ast.js";
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, moduleImports, requiredModule, } from "./bindings.js";
4
4
  import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
@@ -28,6 +28,56 @@ const HOOKS = new Map([
28
28
  ]);
29
29
  /** First members that show a name imported from a local file is used as Vitest's `test`. */
30
30
  const TEST_MEMBERS = new Set([...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS, ...KINDS.keys()]);
31
+ /** Calls Vitest moves to the top of the file before running it: `vi.mock`, `vi.unmock` and `vi.hoisted`. */
32
+ const HOISTED_OBJECTS = new Set(['vi', 'vitest']);
33
+ const HOISTED_METHODS = new Set(['mock', 'unmock', 'hoisted']);
34
+ /**
35
+ * The hoisted calls that are not written at the top level of the file, where Vitest moves them. Vitest 5 fails such a
36
+ * file while it loads it. As in Vitest, `const value = vi.hoisted(...)` and `await vi.hoisted(...)` count as top
37
+ * level, and a file with in-source tests is never checked.
38
+ */
39
+ function nestedHoistedCalls(program) {
40
+ if (hasInSourceTests(program))
41
+ return [];
42
+ const topLevel = new Set(program.body);
43
+ for (const statement of program.body) {
44
+ if (statement.type === 'ExpressionStatement')
45
+ topLevel.add(statement.expression);
46
+ }
47
+ const found = [];
48
+ const parents = [];
49
+ const visit = (node) => {
50
+ if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
51
+ const { object, property } = node.callee;
52
+ const method = !node.callee.computed && property.type === 'Identifier' ? property.name : '';
53
+ if (object.type === 'Identifier' && HOISTED_OBJECTS.has(object.name) && HOISTED_METHODS.has(method)) {
54
+ if (!topLevel.has(method === 'hoisted' ? hoistedValue(node, parents) : node))
55
+ found.push(node);
56
+ }
57
+ }
58
+ parents.push(node);
59
+ for (const child of childNodes(node))
60
+ visit(child);
61
+ parents.pop();
62
+ };
63
+ visit(program);
64
+ return found;
65
+ }
66
+ /** Whether the file has in-source tests, which read `import.meta.vitest`. */
67
+ function hasInSourceTests(node) {
68
+ return (node.type === 'MemberExpression' && isImportMetaVitest(node)) || childNodes(node).some(hasInSourceTests);
69
+ }
70
+ /** What Vitest moves for a `vi.hoisted` call: the declaration it initializes, the `await` around it, or the call. */
71
+ function hoistedValue(call, parents) {
72
+ const declaration = parents.findLast((node) => node.type === 'VariableDeclaration');
73
+ const init = declaration?.type === 'VariableDeclaration' ? declaration.declarations[0]?.init : null;
74
+ const value = init ? unwrap(init) : null;
75
+ if (declaration && (value === call || (value?.type === 'AwaitExpression' && unwrap(value.argument) === call))) {
76
+ return declaration;
77
+ }
78
+ const parent = parents.at(-1);
79
+ return parent?.type === 'AwaitExpression' ? parent : call;
80
+ }
31
81
  /** The names a file binds to Vitest's `test`, `describe`, hooks and namespace. */
32
82
  class VitestBindings {
33
83
  kinds = new Map();
@@ -158,6 +208,12 @@ export class VitestAdapter {
158
208
  this.readModule = readModule;
159
209
  this.bindings = new VitestBindings(parsed.program, file.version);
160
210
  this.problems = [...this.bindings.problems];
211
+ if (atLeast(file.version, 5, 0)) {
212
+ for (const call of nestedHoistedCalls(parsed.program)) {
213
+ const callee = snippet(text(this.file, call.callee));
214
+ this.problems.push(problem('removed-api', call, `Vitest 5 fails a file that calls ${callee}(...) anywhere but its top level, because the call is hoisted there.`, callee));
215
+ }
216
+ }
161
217
  this.imports = moduleImports(parsed.program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
162
218
  if (atLeast(file.version, 4, 1)) {
163
219
  // Vitest's own pattern, over the whole text as Vitest reads it, so the same tags come out.
package/dist/model.d.ts CHANGED
@@ -77,9 +77,13 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
77
77
  reason: string | null;
78
78
  hasDynamicReason: boolean;
79
79
  problems: Problem[];
80
- } | {
81
- kind: 'mode';
82
- mode: Mode;
80
+ }
81
+ /** `test.describe.configure()`: a mode, locks (Playwright 1.64), or both. */
82
+ | {
83
+ kind: 'configure';
84
+ mode: Mode | null;
85
+ locks: string[];
86
+ problems: Problem[];
83
87
  } | {
84
88
  kind: 'hook';
85
89
  fn: FunctionNode | null;
package/dist/options.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CommentsMode } from './records.ts';
2
2
  import type { Framework } from './types.ts';
3
3
  export type { CommentsMode };
4
+ /** @inline */
4
5
  export interface ScanOptions {
5
6
  /** Glob patterns for the test files, relative to `root`. */
6
7
  patterns: string[];
@@ -14,6 +15,7 @@ export interface ScanOptions {
14
15
  /** Which tests get the comments above them. Defaults to `non-active`. */
15
16
  comments?: CommentsMode;
16
17
  }
18
+ /** @inline */
17
19
  export interface ScanSourceOptions {
18
20
  code: string;
19
21
  /** Path of the file from the project root, used in the output and in ids. */
package/dist/records.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Extraction } from './extract.ts';
2
2
  import type { ParsedFile } from './parse.ts';
3
3
  import type { Diagnostic, Framework, SuiteRecord, TestRecord } from './types.ts';
4
+ /** @inline */
4
5
  export type CommentsMode = 'all' | 'non-active' | 'none';
5
6
  export interface FileRecords {
6
7
  suites: SuiteRecord[];
package/dist/records.js CHANGED
@@ -203,7 +203,11 @@ class RecordBuilder {
203
203
  ]),
204
204
  annotations: [...describes.flatMap((scope) => scope.details.annotations), ...pending.annotations],
205
205
  comments,
206
- locks: unique([...describes.flatMap((scope) => scope.details.locks), ...declaration.details.locks]),
206
+ locks: unique([
207
+ ...fileScope.details.locks,
208
+ ...describes.flatMap((scope) => scope.details.locks),
209
+ ...declaration.details.locks,
210
+ ]),
207
211
  meta: Object.keys(meta).length > 0 ? meta : null,
208
212
  isParameterized: tables.length > 0,
209
213
  caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
package/dist/types.d.ts CHANGED
@@ -10,15 +10,26 @@ type Count = number;
10
10
  type Position = number;
11
11
  /** @pattern ^[0-9a-f]{16}$ */
12
12
  type Hash = string;
13
+ /** @inline */
13
14
  export type Framework = 'playwright' | 'vitest';
14
15
  /**
15
16
  * The state the runner applies to a test, after describes and file-level calls are taken into account. `notLoaded`:
16
17
  * the runner refuses to load the file, so none of its tests run.
18
+ *
19
+ * @inline
17
20
  */
18
21
  export type TestState = 'active' | 'skip' | 'fixme' | 'todo' | 'expectedToFail' | 'notLoaded';
19
- /** Where a test's state is set: on the test, on a describe around it, or at file level. */
22
+ /**
23
+ * Where a test's state is set: on the test, on a describe around it, or at file level.
24
+ *
25
+ * @inline
26
+ */
20
27
  export type StateSource = 'test' | 'suite' | 'file';
21
- /** `error`: tests may be missing from the output. `warning`: a value is missing or approximate. */
28
+ /**
29
+ * `error`: tests may be missing from the output. `warning`: a value is missing or approximate.
30
+ *
31
+ * @inline
32
+ */
22
33
  export type DiagnosticLevel = 'error' | 'warning';
23
34
  export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function';
24
35
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
@@ -38,6 +49,7 @@ export interface Inventory {
38
49
  tests: TestRecord[];
39
50
  diagnostics: Diagnostic[];
40
51
  }
52
+ /** @inline */
41
53
  export interface Tool {
42
54
  name: 'test-inventory';
43
55
  /** Version of test-inventory that produced the output. */
@@ -184,7 +196,7 @@ export interface TestRecord {
184
196
  annotations: Annotation[];
185
197
  /** Comment lines directly above the test. By default only filled for tests that are not active. */
186
198
  comments: string[];
187
- /** From the test and its describes. Playwright only. */
199
+ /** From the test, its describes and the `test.describe.configure()` calls around it. Playwright only. */
188
200
  locks: string[];
189
201
  /** Merged from the describes and the test. Vitest only; literal values only. */
190
202
  meta: Record<string, MetaValue> | null;
@@ -202,6 +214,7 @@ export interface TestRecord {
202
214
  /** Declared inside a plain function. */
203
215
  isInFunction: boolean;
204
216
  }
217
+ /** @inline */
205
218
  export interface Annotation {
206
219
  type: string;
207
220
  description: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
package/schema/v1.json CHANGED
@@ -646,7 +646,7 @@
646
646
  "items": {
647
647
  "type": "string"
648
648
  },
649
- "description": "From the test and its describes. Playwright only."
649
+ "description": "From the test, its describes and the `test.describe.configure()` calls around it. Playwright only."
650
650
  },
651
651
  "meta": {
652
652
  "anyOf": [