test-inventory 0.6.1 → 0.8.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +4 -3
  3. package/dist/case-titles.d.ts +27 -0
  4. package/dist/case-titles.js +329 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +6 -5
  7. package/dist/config-dependencies.js +8 -3
  8. package/dist/config-runs.d.ts +22 -0
  9. package/dist/config-runs.js +263 -3
  10. package/dist/config.d.ts +18 -1
  11. package/dist/config.js +160 -14
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +3 -0
  15. package/dist/extract.d.ts +19 -1
  16. package/dist/extract.js +391 -41
  17. package/dist/files.js +3 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +64 -5
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/common.d.ts +6 -0
  24. package/dist/frameworks/common.js +27 -0
  25. package/dist/frameworks/jest.d.ts +46 -0
  26. package/dist/frameworks/jest.js +442 -0
  27. package/dist/frameworks/playwright.d.ts +3 -1
  28. package/dist/frameworks/playwright.js +14 -5
  29. package/dist/frameworks/test-functions.d.ts +169 -0
  30. package/dist/frameworks/test-functions.js +492 -0
  31. package/dist/frameworks/vitest.d.ts +13 -58
  32. package/dist/frameworks/vitest.js +84 -378
  33. package/dist/imported-values.d.ts +22 -0
  34. package/dist/imported-values.js +149 -0
  35. package/dist/model.d.ts +11 -0
  36. package/dist/options.d.ts +16 -1
  37. package/dist/options.js +9 -2
  38. package/dist/parse.js +17 -2
  39. package/dist/records.js +57 -15
  40. package/dist/resolve.js +39 -36
  41. package/dist/scan-file.d.ts +5 -0
  42. package/dist/scan-file.js +15 -8
  43. package/dist/scan-source.js +3 -1
  44. package/dist/scan.js +31 -10
  45. package/dist/types.d.ts +24 -6
  46. package/dist/values.d.ts +49 -1
  47. package/dist/values.js +531 -19
  48. package/package.json +1 -1
  49. package/schema/v1.json +33 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - `--framework` (library: `framework`) is now optional. Without it, the scan takes the runner the project uses: the one
8
+ whose package a `package.json` in the root or below it lists, or whose config file is there, else one a `package.json`
9
+ above the root lists. A project with several runners, or none, gets a usage error (a `TypeError` in the library) that
10
+ names what it found.
11
+
12
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - Jest 29 and 30: `--framework jest` (library: `framework: 'jest'`) lists the tests of Jest files with the same keys as
13
+ Playwright and Vitest tests. It reads Jest's globals, `fit`, `xit`, `xtest`, `fdescribe` and `xdescribe` among them,
14
+ imports and `require()` of `@jest/globals`, the chains Jest has, such as `test.concurrent.only.each`, `.failing` as
15
+ `expectedToFail`, the timeout passed third, `jest.retryTimes()` as `retries` for every test of the file (from 30.5,
16
+ with `entireDescribe`, of its describe) and `waitBeforeRetry` as `retryDelay`, and `@group` pragmas as tags, as
17
+ jest-runner-groups reads them. `--expand-cases` titles each case as jest-each 29 and 30 do. Calls Jest refuses, such
18
+ as `test.only.concurrent`, `test('title')` without a function or an async describe, mark the file's tests `notLoaded`.
19
+
20
+ Without globs, the test files come from the Jest config, read as Jest reads it: `jest.config.*`, else `package.json`,
21
+ with `roots`, `testMatch`, `testRegex`, the ignore patterns, `moduleFileExtensions`, `projects` named by
22
+ `displayName`, `testTimeout` and `injectGlobals`, also through Jest 30.3's `defineConfig()` and `mergeConfig()`;
23
+ without any config, Jest's defaults apply. `framework` in the output
24
+ can now be `jest`, an open set, so `schemaVersion` stays 1.
25
+
26
+ The `cases-not-expanded` message no longer names Vitest, as Jest tables can get it too.
27
+
28
+ ### Patch Changes
29
+
30
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - `require()` of a local file is followed as an import is: tables, titles and other values it exports, with
31
+ `module.exports` or `exports.name`, are read, and helper functions it exports that declare tests are followed. A
32
+ helper function a shared file imports and exports again, as an `index.js` of helpers does, is followed to where it is
33
+ written, instead of getting an `unresolved-test-import` error. A helpers file may export a test function it takes from
34
+ the runner's globals, as `export const pendingTest = xit`, or from the framework's module, with its modifiers.
35
+
36
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - A call the runner refuses, such as `test.only.skip(...)`, that is only reached under a condition no longer marks every
37
+ test of its file `notLoaded`, as the file loads unless the condition holds; the call is still reported. A test function
38
+ chosen with `&&`, `||` or `??`, as in `const onCi = process.env.CI && test.skip || test`, is read like one chosen with
39
+ `?:`. In a config, a list with a spread the scan can't work out keeps the items it can.
40
+
41
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - A config that `require()`s a local file, as a CommonJS config does with a shared base, now reads what that file
42
+ exports, as an `import` of it already did, and a JSON file, required or imported, is read as its value.
43
+
44
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - A table or loop gets its `caseCount` when its length is known even if one of its values isn't, as in
45
+ `[NaN, Infinity].forEach(...)` or a table with a call in a row. A test or describe titled by a function or class
46
+ written in place, as `describe(function OrderBook() {}, ...)`, takes its name, as the runners do. Code the parser
47
+ fails to hand over, such as a JSX name written as a Unicode escape, gets a `parse-error` instead of an
48
+ `internal-error`.
49
+
50
+ - [#75](https://github.com/Huldoser/test-inventory/pull/75) [`f6690bb`](https://github.com/Huldoser/test-inventory/commit/f6690bbe5e49ce048ec4169f21bed23f702942d1) - `dependencyHash` is faster on large projects: the files a test file and its describes reach are put together once for
51
+ all their tests, and a workspace's packages are found once, not once per folder that imports one. A scan of the Nx
52
+ repository with 17,571 tests went from 19.4 to 11.9 seconds. Hashes are unchanged.
53
+
54
+ ## 0.7.0
55
+
56
+ ### Minor Changes
57
+
58
+ - [#72](https://github.com/Huldoser/test-inventory/pull/72) [`cf784f0`](https://github.com/Huldoser/test-inventory/commit/cf784f070e66523df1d68c546f033e1f495f0f49) - `--expand-cases` (library: `expandCases: true`) lists each case of a `.each` or `.for` table and each pass of a loop
59
+ whose values the scan can work out as its own record, with the title the runner gives it. Vitest's titles follow the
60
+ rules of the project's Vitest version, 3.0 to 5: `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%c`, `%#`, `%$` and `$name`,
61
+ with values quoted and cut as that version does; they match the names Vitest 3.0, 3.1, 3.2, 4.0, 4.1 and 5.0 list.
62
+ Each case's values are bound in its callback, so titles, tags and conditions built from them are worked out per case,
63
+ and an `if`, `?:`, `&&`, `||` or `??` that reads nothing but them registers only the branch it takes. Loops in test
64
+ and hook bodies and loops over a `var` are not expanded, and a file starts expansions up to 1,000 loop passes and
65
+ describe cases.
66
+ Suites and tests have a new key, `caseIndex`: the case's index from 0, as `%#` counts, or `null` without the option. A
67
+ table that can't be expanded stays one record with a new `cases-not-expanded` warning. Without the option the output
68
+ is as before, apart from `caseIndex: null` on every suite and test.
69
+
70
+ - [#72](https://github.com/Huldoser/test-inventory/pull/72) [`cf784f0`](https://github.com/Huldoser/test-inventory/commit/cf784f070e66523df1d68c546f033e1f495f0f49) - Values imported from other files of the project are now worked out: a title, tag, annotation, reason, run setting or
71
+ `.each` table built from a constant, enum or default export of another module, through re-exports, `export *`,
72
+ `import * as` and JSON files, found the same way as helper functions (relative paths, tsconfig `paths`, `#imports`,
73
+ workspace packages). Values from installed packages are still not read, and keep their diagnostic.
74
+
75
+ A test in a `for...of`, `for...in`, `forEach` or `map` loop over values the scan can work out now has `caseCount`, the
76
+ number of tests the runner registers, and no `test-in-loop` warning; the warning now marks a loop the scan can't
77
+ count. Values also work out arithmetic on numbers, as in `30 * 1000`, `?:`, `&&`, `||`, `??`, comparisons,
78
+ `Object.keys()`, `Object.values()`, `Object.entries()`, `JSON.stringify()`, `String()`, and a string's `toUpperCase()`,
79
+ `toLowerCase()` and `trim()` and an array's `join()`. A skip condition that works out to a constant, such as
80
+ `side === 'short'` in a helper called with `'buy'`, now sets the state it gives instead of being kept as a condition.
81
+
82
+ Code after an `if` whose branch always returns or throws, as in `if (!strategy.enabled) return;` in a helper, is now
83
+ under the opposite condition, so its tests have `isInCondition` and a loop around them no count. A tagged-template
84
+ table is counted as Vitest splits it: outer pipes are columns, an incomplete last row is left out, and a template
85
+ without values is one case.
86
+
87
+ A test whose title was its source text and is now worked out gets a new `id`, once.
88
+
89
+ ### Patch Changes
90
+
91
+ - [#71](https://github.com/Huldoser/test-inventory/pull/71) [`bf8383f`](https://github.com/Huldoser/test-inventory/commit/bf8383f009a73a026d128cd8e050505cdd63cfb5) - A name destructured from a test object, as in `const { describe, beforeEach } = test`, is now read as that member:
92
+ `beforeEach(...)` is a hook, `describe(...)` a describe and `skip(...)` a skipped test, for Playwright and Vitest,
93
+ also nested, as in `const { describe: { serial } } = test`. Every such name was read as `test` itself, so a hook got
94
+ `test-without-body` and every test of the file `notLoaded`, and the tests inside a describe were left out with
95
+ `nested-test` warnings.
96
+
97
+ - [#71](https://github.com/Huldoser/test-inventory/pull/71) [`bf8383f`](https://github.com/Huldoser/test-inventory/commit/bf8383f009a73a026d128cd8e050505cdd63cfb5) - When the root and the folders above it don't have the framework, its version is now read from the shallowest
98
+ `package.json` below the root that declares it and holds scanned files, as `code/package.json` in a monorepo, with
99
+ that package's lockfile or installed copy first. The version was `null` there, so the newest rules applied: Vitest
100
+ 5's, which refuse a file that calls `vi.mock` inside a block, made every test of such a file `notLoaded` in a project
101
+ on Vitest 3.
102
+
3
103
  ## 0.6.1
4
104
 
5
105
  ### Patch Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Test metadata from source code, not from test results.
4
4
 
5
- test-inventory reads Playwright and Vitest test files and lists every test declaration as JSON: its title, file and
5
+ test-inventory reads Playwright, Vitest and Jest test files and lists every test declaration as JSON: its title, file and
6
6
  lines, the describes around it, its tags and annotations, and whether it is active, skipped, fixme, todo, expected
7
7
  to fail or in a file the runner refuses to load, with the condition when the state only applies sometimes. It parses
8
8
  the code and runs nothing, so it needs no browsers, no build and no runner configuration.
@@ -15,7 +15,7 @@ Documentation: [huldoser.github.io/test-inventory](https://huldoser.github.io/te
15
15
  npm install --save-dev test-inventory
16
16
  ```
17
17
 
18
- Node.js 22.12 or newer, on Linux, macOS or Windows. Playwright 1.42 or newer, Vitest 3.0 or newer.
18
+ Node.js 22.12 or newer, on Linux, macOS or Windows. Playwright 1.42 or newer, Vitest 3.0 or newer, Jest 29.0 or newer.
19
19
 
20
20
  ## Command line
21
21
 
@@ -25,7 +25,8 @@ Node.js 22.12 or newer, on Linux, macOS or Windows. Playwright 1.42 or newer, Vi
25
25
  npx test-inventory "tests/**/*.spec.ts" --framework playwright --output inventory.json
26
26
  ```
27
27
 
28
- Without the glob, the test files are those the Playwright or Vitest configs in the project run.
28
+ Without the glob, the test files are those the project's Playwright, Vitest or Jest configs run. Without
29
+ `--framework`, the scan takes the runner the project uses, and asks for `--framework` when it uses several.
29
30
 
30
31
  For this spec from a trading app's UI tests, `tests/alerts.spec.ts`:
31
32
 
@@ -0,0 +1,27 @@
1
+ import type { Value } from './values.ts';
2
+ /**
3
+ * The title Vitest gives one case of a `.each` or `.for` table, read from Vitest's `formatTitle` in 3.0, 3.1, 4.1
4
+ * and 5.0: `%#` and `%$` for the case's index, printf placeholders for its items, and `$name` for a key of its object.
5
+ * Vitest 3 formats the whole title and then replaces `$name`, also inside the values it put in; Vitest 4 and 5 format
6
+ * each placeholder on its own and replace `$name` only in the text between them. Vitest 3.0 has no `%$`, and reads
7
+ * `$name` only when the case is an object. Null when the title shows a value the scan doesn't format the way Vitest
8
+ * does, which is any array or object printed as a whole.
9
+ *
10
+ * `version` is Vitest's `[major, minor]`, null for the newest rules. `truncate` is the length Vitest shortens a value to,
11
+ * 40 unless the config sets `chaiConfig.truncateThreshold` (3 and 4) or `taskTitleValueFormatTruncate` (5); 0 keeps
12
+ * values whole.
13
+ */
14
+ export declare function vitestCaseTitle(template: string, items: readonly Value[], index: number, version: readonly [number, number] | null, truncate?: number): string | null;
15
+ /**
16
+ * The title Jest gives one row of a `.each` table of arrays, or of single values, read from `jest-each` 29.0, 29.7,
17
+ * 30.0 and 30.5: `%#` and from 30.0 `%$` for the row's index, then one placeholder per item, in order. `%p` shows the
18
+ * item with pretty-format and the others with Node's `util.format`, which Jest calls too. Items left over are dropped.
19
+ * Null when an item is an array or object that `util.format` would show, which differs between Node versions.
20
+ */
21
+ export declare function jestRowTitle(template: string, row: readonly Value[], index: number, version: readonly [number, number] | null): string | null;
22
+ /**
23
+ * The title Jest gives one case of a `.each` table of objects, or one row of a tagged-template table: `$name` and
24
+ * `$name.path` for the case's values, and `$#` for its index. `keepsEscapes` is set for a tagged template, where `%%`
25
+ * stays as written. Null when Jest fails the table, as when a path goes through `null` before 30.0.
26
+ */
27
+ export declare function jestKeysTitle(template: string, item: Record<string, Value>, index: number, version: readonly [number, number] | null, keepsEscapes: boolean): string | null;
@@ -0,0 +1,329 @@
1
+ import { format } from 'node:util';
2
+ /**
3
+ * The title Vitest gives one case of a `.each` or `.for` table, read from Vitest's `formatTitle` in 3.0, 3.1, 4.1
4
+ * and 5.0: `%#` and `%$` for the case's index, printf placeholders for its items, and `$name` for a key of its object.
5
+ * Vitest 3 formats the whole title and then replaces `$name`, also inside the values it put in; Vitest 4 and 5 format
6
+ * each placeholder on its own and replace `$name` only in the text between them. Vitest 3.0 has no `%$`, and reads
7
+ * `$name` only when the case is an object. Null when the title shows a value the scan doesn't format the way Vitest
8
+ * does, which is any array or object printed as a whole.
9
+ *
10
+ * `version` is Vitest's `[major, minor]`, null for the newest rules. `truncate` is the length Vitest shortens a value to,
11
+ * 40 unless the config sets `chaiConfig.truncateThreshold` (3 and 4) or `taskTitleValueFormatTruncate` (5); 0 keeps
12
+ * values whole.
13
+ */
14
+ export function vitestCaseTitle(template, items, index, version, truncate = 40) {
15
+ const rules = version === null || version[0] >= 5 ? 5 : version[0];
16
+ const isFirst3 = version !== null && version[0] === 3 && version[1] === 0;
17
+ let text = template;
18
+ if (text.includes('%#') || (!isFirst3 && text.includes('%$'))) {
19
+ text = text.replace(/%%/g, ESCAPED).replace(/%#/g, String(index));
20
+ if (!isFirst3)
21
+ text = text.replace(/%\$/g, String(index + 1));
22
+ text = text.replaceAll(ESCAPED, '%%');
23
+ }
24
+ const count = text.split('%').length - 1;
25
+ if (text.includes('%f'))
26
+ text = negativeFloats(text, items);
27
+ const isObjectItem = isObject(items[0]);
28
+ const attribute = (key) => {
29
+ const isArrayKey = !isFirst3 && /^\d+$/.test(key);
30
+ if (!isObjectItem && !isArrayKey)
31
+ return `$${key}`;
32
+ const element = isArrayKey ? objectAttr(items, key) : undefined;
33
+ const value = isObjectItem ? objectAttr(items[0], key, element) : element;
34
+ if (value === NOT_PLAIN)
35
+ return null;
36
+ if (rules === 5)
37
+ return typeof value === 'string' ? shorten(value, truncate) : prettyFormat(value, truncate);
38
+ return loupe(value, truncate === 0 ? Infinity : truncate);
39
+ };
40
+ if (rules === 3) {
41
+ const formatted = printf(text, items.slice(0, count), rules, truncate);
42
+ return formatted === null ? null : replaceAttributes(formatted, ATTRIBUTE_3, attribute);
43
+ }
44
+ let output = '';
45
+ let used = 0;
46
+ let last = 0;
47
+ for (const match of text.matchAll(PLACEHOLDER)) {
48
+ const between = replaceAttributes(text.slice(last, match.index), rules === 5 ? ATTRIBUTE_5 : ATTRIBUTE_3, attribute);
49
+ if (between === null)
50
+ return null;
51
+ output += between;
52
+ // Each placeholder, `%%` included, takes the next item, also one past the end of the case.
53
+ const placeholder = printf(match[0], [items[used++]], rules, truncate);
54
+ if (placeholder === null)
55
+ return null;
56
+ output += placeholder;
57
+ last = match.index + match[0].length;
58
+ }
59
+ const rest = replaceAttributes(text.slice(last), rules === 5 ? ATTRIBUTE_5 : ATTRIBUTE_3, attribute);
60
+ return rest === null ? null : output + rest;
61
+ }
62
+ const ESCAPED = '__vitest_escaped_%__';
63
+ const PLACEHOLDER = /%[sdjifoOc%]/g;
64
+ const ATTRIBUTE_3 = /\$([$\w.]+)/g;
65
+ const ATTRIBUTE_5 = /\$([$\p{ID_Continue}.]+)/gu;
66
+ /** A key that leads to something other than a plain value, such as `toString`, which Vitest would show as a function. */
67
+ const NOT_PLAIN = Symbol('not plain');
68
+ function isPrimitive(value) {
69
+ return typeof value !== 'object' || value === null;
70
+ }
71
+ function isObject(value) {
72
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
73
+ }
74
+ /** Vitest's `objectAttr`: a path such as `order.qty`, `legs[0].side` or `0`, read as `new Object(value)[key]` reads it. */
75
+ function objectAttr(source, path, fallback) {
76
+ let result = source;
77
+ for (const key of path.replace(/\[(\d+)\]/g, '.$1').split('.')) {
78
+ if (result === null || result === undefined)
79
+ return fallback;
80
+ const object = Object(result);
81
+ if (Object.hasOwn(object, key)) {
82
+ result = object[key];
83
+ }
84
+ else if (key in object) {
85
+ return NOT_PLAIN;
86
+ }
87
+ else {
88
+ return fallback;
89
+ }
90
+ if (result === undefined)
91
+ return fallback;
92
+ }
93
+ return result;
94
+ }
95
+ /** `%f` of `-0` shows a minus, which Vitest adds for the item at the same place among the items. */
96
+ function negativeFloats(template, items) {
97
+ let text = template;
98
+ template.match(/%f/g).forEach((_, index) => {
99
+ if (!Object.is(items[index], -0))
100
+ return;
101
+ let occurrence = 0;
102
+ text = text.replace(/%f/g, (match) => (++occurrence === index + 1 ? '-%f' : match));
103
+ });
104
+ return text;
105
+ }
106
+ function replaceAttributes(text, pattern, attribute) {
107
+ let output = '';
108
+ let last = 0;
109
+ for (const match of text.matchAll(pattern)) {
110
+ const value = attribute(match[1]);
111
+ if (value === null)
112
+ return null;
113
+ output += text.slice(last, match.index) + value;
114
+ last = match.index + match[0].length;
115
+ }
116
+ return output + text.slice(last);
117
+ }
118
+ /** Vitest's `format()` for a template and its arguments, for the values it shows the way the scan does. */
119
+ function printf(template, args, rules, truncate) {
120
+ let used = 0;
121
+ let output = '';
122
+ let last = 0;
123
+ for (const match of template.matchAll(PLACEHOLDER)) {
124
+ const placeholder = match[0];
125
+ const formatted = placeholder === '%%'
126
+ ? '%'
127
+ : used >= args.length
128
+ ? placeholder
129
+ : formatValue(placeholder, args[used++], rules, truncate);
130
+ if (formatted === null)
131
+ return null;
132
+ output += template.slice(last, match.index) + formatted;
133
+ last = match.index + placeholder.length;
134
+ }
135
+ output += template.slice(last);
136
+ // Arguments beyond the placeholders are appended, as `console.log` does.
137
+ for (const value of args.slice(used)) {
138
+ if (!isPrimitive(value))
139
+ return null;
140
+ output += ` ${String(value)}`;
141
+ }
142
+ return output;
143
+ }
144
+ function formatValue(placeholder, value, rules, truncate) {
145
+ switch (placeholder) {
146
+ case '%s':
147
+ if (!isPrimitive(value))
148
+ return null;
149
+ return Object.is(value, -0) ? '-0' : String(value);
150
+ case '%d':
151
+ return Number(value).toString();
152
+ case '%i':
153
+ return isPrimitive(value) ? Number.parseInt(String(value)).toString() : null;
154
+ case '%f':
155
+ return isPrimitive(value) ? Number.parseFloat(String(value)).toString() : null;
156
+ case '%j':
157
+ // JSON.stringify(undefined) gives undefined, which the title shows as text.
158
+ return value === undefined ? 'undefined' : JSON.stringify(value);
159
+ case '%c':
160
+ return '';
161
+ default:
162
+ // `%o` and `%O` inspect the value: with loupe in Vitest 3 and 4, with pretty-format and the truncate option in 5.
163
+ if (!isPrimitive(value))
164
+ return null;
165
+ return rules === 5 ? prettyFormat(value, truncate) : loupe(value, Infinity);
166
+ }
167
+ }
168
+ /** A string cut to `length` with an ellipsis, without splitting a surrogate pair; 0 keeps it whole. */
169
+ function shorten(text, length) {
170
+ if (length === 0 || text.length <= length)
171
+ return text;
172
+ let end = length - 1;
173
+ if (isHighSurrogate(text[end - 1]))
174
+ end -= 1;
175
+ return `${text.slice(0, end)}…`;
176
+ }
177
+ function isHighSurrogate(char) {
178
+ return char !== undefined && char >= '\ud800' && char <= '\udbff';
179
+ }
180
+ /** Vitest 5's `inspect()` of a value, through pretty-format with single quotes. */
181
+ function prettyFormat(value, truncate) {
182
+ if (!isPrimitive(value))
183
+ return null;
184
+ if (typeof value !== 'string')
185
+ return Object.is(value, -0) ? '-0' : String(value);
186
+ const quoted = `'${value}'`;
187
+ if (truncate === 0 || quoted.length <= truncate)
188
+ return quoted;
189
+ let end = truncate - 1;
190
+ if (end > 0 && isHighSurrogate(quoted[end - 1]))
191
+ end -= 1;
192
+ return `'${quoted.slice(1, end)}…'`;
193
+ }
194
+ const LOUPE_ESCAPED = new RegExp("['\\u0000-\\u001f\\u007f-\\u009f\\u00ad\\u0600-\\u0604\\u070f\\u17b4\\u17b5\\u200c-\\u200f\\u2028-\\u202f\\u2060-\\u206f\\ufeff\\ufff0-\\uffff]", 'g');
195
+ const LOUPE_ESCAPES = {
196
+ '\b': '\\b',
197
+ '\t': '\\t',
198
+ '\n': '\\n',
199
+ '\f': '\\f',
200
+ '\r': '\\r',
201
+ "'": "\\'",
202
+ };
203
+ /** loupe's `inspect()` of a value, as Vitest 3 and 4 show it, cut to `truncate`. */
204
+ function loupe(value, truncate) {
205
+ if (!isPrimitive(value))
206
+ return null;
207
+ if (typeof value === 'string') {
208
+ const escaped = value.replace(LOUPE_ESCAPED, (char) => LOUPE_ESCAPES[char] ?? `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}`);
209
+ return `'${loupeTruncate(escaped, truncate - 2)}'`;
210
+ }
211
+ if (typeof value !== 'number')
212
+ return String(value);
213
+ if (Number.isNaN(value) || !Number.isFinite(value))
214
+ return String(value);
215
+ if (value === 0)
216
+ return Object.is(value, -0) ? '-0' : '+0';
217
+ return loupeTruncate(String(value), truncate);
218
+ }
219
+ function loupeTruncate(text, length) {
220
+ if (length < 1 && text.length > 1)
221
+ return '…';
222
+ if (text.length <= length || text.length <= 1)
223
+ return text;
224
+ let end = length - 1;
225
+ if (end > 0 && isHighSurrogate(text[end - 1]))
226
+ end -= 1;
227
+ return `${text.slice(0, end)}…`;
228
+ }
229
+ /** The text Jest puts in place of `%%` and of `%` in string values while it formats a title. */
230
+ const JEST_ESCAPE = '@@__JEST_EACH_PLACEHOLDER_ESCAPE__@@';
231
+ const JEST_PLACEHOLDER = /%[#Odfijops]/;
232
+ /** The placeholders `util.format` takes an argument for. */
233
+ const NODE_PLACEHOLDER = /%[sdifjoOc]/;
234
+ /**
235
+ * The title Jest gives one row of a `.each` table of arrays, or of single values, read from `jest-each` 29.0, 29.7,
236
+ * 30.0 and 30.5: `%#` and from 30.0 `%$` for the row's index, then one placeholder per item, in order. `%p` shows the
237
+ * item with pretty-format and the others with Node's `util.format`, which Jest calls too. Items left over are dropped.
238
+ * Null when an item is an array or object that `util.format` would show, which differs between Node versions.
239
+ */
240
+ export function jestRowTitle(template, row, index, version) {
241
+ const isJest30 = version === null || version[0] >= 30;
242
+ const replacesWithFunction = version === null || version[0] > 30 || (version[0] === 30 && version[1] >= 5);
243
+ let text = template.replaceAll('%%', JEST_ESCAPE).replace('%#', String(index));
244
+ if (isJest30)
245
+ text = text.replace('%$', String(index + 1));
246
+ for (const item of row) {
247
+ const placeholder = JEST_PLACEHOLDER.exec(text);
248
+ if (placeholder === null)
249
+ continue;
250
+ const value = typeof item === 'string' ? item.replaceAll('%', JEST_ESCAPE) : item;
251
+ if (placeholder[0] === '%p') {
252
+ const pretty = prettyFormatMin(value);
253
+ // Before 30.5, `$&` and the like in the value are read as replacement patterns, as JavaScript's `replace` does.
254
+ text = replacesWithFunction ? text.replace('%p', () => pretty) : text.replace('%p', pretty);
255
+ }
256
+ else if (placeholder[0] === '%j' && replacesWithFunction) {
257
+ const json = value === undefined ? 'undefined' : JSON.stringify(value);
258
+ text = text.replace('%j', () => json);
259
+ }
260
+ else if (isPrimitive(value) || (placeholder[0] === '%j' && NODE_PLACEHOLDER.exec(text)?.[0] === '%j')) {
261
+ text = format(text, value);
262
+ }
263
+ else {
264
+ return null;
265
+ }
266
+ }
267
+ return text.replaceAll(JEST_ESCAPE, '%');
268
+ }
269
+ /**
270
+ * The title Jest gives one case of a `.each` table of objects, or one row of a tagged-template table: `$name` and
271
+ * `$name.path` for the case's values, and `$#` for its index. `keepsEscapes` is set for a tagged template, where `%%`
272
+ * stays as written. Null when Jest fails the table, as when a path goes through `null` before 30.0.
273
+ */
274
+ export function jestKeysTitle(template, item, index, version, keepsEscapes) {
275
+ let text;
276
+ try {
277
+ text = interpolateKeys(template, item, version);
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ const title = text.replace('$#', String(index));
283
+ return keepsEscapes ? title : title.replaceAll('%%', '%');
284
+ }
285
+ /** Jest's `interpolateVariables`, which throws where Jest's does. */
286
+ function interpolateKeys(template, item, version) {
287
+ const [major, minor] = version ?? [Infinity, 0];
288
+ const show = (value) => (isPrimitive(value) ? String(value) : prettyFormatMin(value));
289
+ if (major === 29 && minor < 4) {
290
+ // Each match of each key is replaced in turn, as text, so a value can hold the next key's match.
291
+ const matches = Object.keys(item).flatMap((key) => template.match(new RegExp(`\\$${key}[\\.\\w]*`, 'g')) ?? []);
292
+ return matches.reduce((title, match) => title.replace(match, show(keyPath(item, match, false))), template);
293
+ }
294
+ let keys = Object.keys(item);
295
+ if (major > 30 || (major === 30 && minor >= 5)) {
296
+ keys = keys.filter((key) => key.length > 0).sort((a, b) => b.length - a.length);
297
+ if (keys.length === 0)
298
+ return template;
299
+ keys = keys.map((key) => key.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'));
300
+ }
301
+ const pattern = new RegExp(`\\$(${keys.join('|')})[.\\w]*`, 'g');
302
+ return template.replaceAll(pattern, (match) => show(keyPath(item, match, major >= 30)));
303
+ }
304
+ /** The value a `$name.path` match leads to, as Jest's `getPath` reads it; from 30.0 `null` and `undefined` end it. */
305
+ function keyPath(item, match, endsAtNull) {
306
+ let value = item;
307
+ for (const key of match.slice(1).split('.')) {
308
+ if (endsAtNull && (value === null || value === undefined))
309
+ return String(value);
310
+ // Throws on `null` and `undefined` before 30.0, as Jest's does.
311
+ if (!key || !Object.prototype.hasOwnProperty.call(value, key))
312
+ return value;
313
+ value = value[key];
314
+ }
315
+ return endsAtNull && (value === null || value === undefined) ? String(value) : value;
316
+ }
317
+ /** pretty-format with `{ min: true, maxDepth: 1 }`, as Jest shows a value in a title. */
318
+ function prettyFormatMin(value, depth = 0) {
319
+ if (typeof value === 'string')
320
+ return `"${value.replaceAll(/"|\\/g, '\\$&')}"`;
321
+ if (isPrimitive(value))
322
+ return Object.is(value, -0) ? '-0' : String(value);
323
+ if (depth >= 1)
324
+ return Array.isArray(value) ? '[Array]' : '[Object]';
325
+ if (Array.isArray(value))
326
+ return `[${value.map((item) => prettyFormatMin(item, depth + 1)).join(', ')}]`;
327
+ const keys = Object.keys(value).sort();
328
+ return `{${keys.map((key) => `${prettyFormatMin(key)}: ${prettyFormatMin(value[key], depth + 1)}`).join(', ')}}`;
329
+ }
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,6 @@ export interface Io {
6
6
  /** Read for `--related -`. */
7
7
  stdin?: Readable;
8
8
  }
9
- 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. Without globs, the\ntest files are those the Playwright or Vitest configs in the root and the folders below it run.\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 --related <file> keep tests a change to this file can affect; can be repeated; - reads files, one per\n line, from standard input, as git diff --name-only writes them\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";
9
+ export declare const HELP = "Usage: test-inventory [glob...] [options]\n\nLists the tests in the matching files as JSON, read from the source code without running them. Without globs, the\ntest files are those the Playwright, Vitest or Jest configs in the root and the folders below it run.\n\nOptions:\n --framework <name> playwright, vitest or jest (default: the one the project uses)\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 --expand-cases one record per case of a .each or .for table and per pass of a loop, with its title\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 --related <file> keep tests a change to this file can affect; can be repeated; - reads files, one per\n line, from standard input, as git diff --name-only writes them\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";
10
10
  /** Runs the command line with the given arguments and streams. Resolves to the exit code. */
11
11
  export declare function run(argv: string[], io: Io): Promise<number>;
package/dist/cli.js CHANGED
@@ -8,17 +8,18 @@ import { writeInventory, writeInventoryLines } from "./json.js";
8
8
  import { OptionsError, toPosixPath } from "./options.js";
9
9
  import { scan } from "./scan.js";
10
10
  import { toolVersion } from "./version.js";
11
- export const HELP = `Usage: test-inventory [glob...] --framework <playwright|vitest> [options]
11
+ export const HELP = `Usage: test-inventory [glob...] [options]
12
12
 
13
13
  Lists the tests in the matching files as JSON, read from the source code without running them. Without globs, the
14
- test files are those the Playwright or Vitest configs in the root and the folders below it run.
14
+ test files are those the Playwright, Vitest or Jest configs in the root and the folders below it run.
15
15
 
16
16
  Options:
17
- --framework <name> playwright or vitest (required)
17
+ --framework <name> playwright, vitest or jest (default: the one the project uses)
18
18
  --root <dir> project root; paths in the output are relative to it (default: current directory)
19
19
  --ignore <glob> leave out matching files; can be repeated (node_modules is always left out)
20
20
  --framework-version <x.y.z> apply the rules of this framework version instead of the one in the project
21
21
  --comments <mode> comments above tests: all, non-active or none (default: non-active)
22
+ --expand-cases one record per case of a .each or .for table and per pass of a loop, with its title
22
23
  --state <state> keep tests in this state, such as skip or fixme; can be repeated
23
24
  --tag <tag> keep tests with this tag, such as @smoke; can be repeated
24
25
  --grep <regex> keep tests whose full name matches, ignoring case
@@ -114,6 +115,7 @@ export async function run(argv, io) {
114
115
  ignore: { type: 'string', multiple: true },
115
116
  'framework-version': { type: 'string' },
116
117
  comments: { type: 'string' },
118
+ 'expand-cases': { type: 'boolean' },
117
119
  state: { type: 'string', multiple: true },
118
120
  tag: { type: 'string', multiple: true },
119
121
  grep: { type: 'string' },
@@ -141,8 +143,6 @@ export async function run(argv, io) {
141
143
  io.stdout.write(`${toolVersion}\n`);
142
144
  return FINISHED;
143
145
  }
144
- if (values.framework === undefined)
145
- return fail('--framework is required: playwright or vitest.');
146
146
  const format = values.format ?? 'json';
147
147
  if (!FORMATS.includes(format))
148
148
  return fail('--format must be json or jsonl.');
@@ -180,6 +180,7 @@ export async function run(argv, io) {
180
180
  ignore: values.ignore ?? [],
181
181
  frameworkVersion: values['framework-version'],
182
182
  comments: values.comments,
183
+ expandCases: values['expand-cases'],
183
184
  });
184
185
  const filtered = filterInventory(inventory, filter);
185
186
  result = fields === undefined ? filtered : pickTestFields(filtered, fields);
@@ -16,6 +16,7 @@ const SNAPSHOT_KEYS = {
16
16
  ['test', 'browser', 'expect', 'toMatchScreenshot', 'resolveScreenshotPath'],
17
17
  ['test', 'browser', 'expect', 'toMatchScreenshot', 'screenshotDirectory'],
18
18
  ],
19
+ jest: [['snapshotResolver']],
19
20
  };
20
21
  /** The keys of a config that name setup files, by framework; Vitest's are a project's own. */
21
22
  const SETUP_KEYS = {
@@ -24,6 +25,7 @@ const SETUP_KEYS = {
24
25
  ['test', 'setupFiles'],
25
26
  ['test', 'globalSetup'],
26
27
  ],
28
+ jest: [['setupFiles'], ['setupFilesAfterEnv'], ['globalSetup'], ['globalTeardown']],
27
29
  };
28
30
  const UNKNOWN_PROJECTS = "The projects that run the file can't be worked out, so dependencyHash doesn't cover their setup.";
29
31
  /**
@@ -86,7 +88,9 @@ export class ConfigDependencies {
86
88
  const found = written(settings, keys);
87
89
  for (const item of found ? listOf(found.setting) : []) {
88
90
  if (item.kind === 'value' && typeof item.value === 'string') {
89
- const read = this.setupFile(root, item.value);
91
+ // Jest's `<rootDir>` at the start of a path is the project's root.
92
+ const file = item.value.startsWith('<rootDir>') ? `.${item.value.slice(9)}` : item.value;
93
+ const read = this.setupFile(root, file);
90
94
  if (read && !('reason' in read))
91
95
  modules.add(read);
92
96
  else if (read) {
@@ -124,13 +128,14 @@ export class ConfigDependencies {
124
128
  ? projects
125
129
  : [{ settings: placement.settings, root: folder, container: null }];
126
130
  // Vitest runs the global setup of the config it starts with for every project, extending it or not.
127
- setup(own, config.settings, folder, ['test', 'globalSetup']);
131
+ if (this.framework === 'vitest')
132
+ setup(own, config.settings, folder, ['test', 'globalSetup']);
128
133
  for (const project of running) {
129
134
  // A file several projects run may be run by one from another config, which the file runs with too; the config
130
135
  // reader has read that config, so it can be read.
131
136
  const written = 'config' in project ? this.module(project.config.file) : own;
132
137
  modules.add(written);
133
- for (const keys of SETUP_KEYS.vitest)
138
+ for (const keys of SETUP_KEYS[this.framework])
134
139
  setup(written, project.settings, project.root, keys);
135
140
  const { container } = project;
136
141
  const outer = container && this.module(container.file);
@@ -58,6 +58,8 @@ export declare class ConfigRuns {
58
58
  private readonly projects;
59
59
  private readonly ignoreRules;
60
60
  private readonly folderStates;
61
+ /** The project Jest runs with when no config is found up to the root. */
62
+ private jestDefaults;
61
63
  /** The Vitest configs whose projects are being found, with their names, to stop at one whose projects name itself. */
62
64
  private readonly expanding;
63
65
  constructor(configs: Configs, framework: Framework, version: [number, number] | null, findsFiles: boolean);
@@ -107,10 +109,30 @@ export declare class ConfigRuns {
107
109
  /** A folder's status and the rules inside it, worked out once per project folder however many files it holds. */
108
110
  private folderState;
109
111
  private rulesIn;
112
+ /** A config for the defaults of a runner started where no config is found. */
113
+ private noConfig;
114
+ /**
115
+ * Jest's projects: the config itself, or the entries of its `projects`. A path or glob there names a folder, whose
116
+ * config is the one Jest finds from it, or a config file; an object is a project whose `rootDir` defaults to the
117
+ * config's folder. The config's own test files then count only when an entry names its folder.
118
+ */
119
+ private jestProjects;
120
+ /**
121
+ * The files a Jest project runs, as Jest's SearchSource picks them: under one of `roots`, with an extension in
122
+ * `moduleFileExtensions`, not left out by `modulePathIgnorePatterns`, matching `testMatch` or `testRegex`, and not
123
+ * `testPathIgnorePatterns`. Paths and patterns are matched with `/`, and `<rootDir>` stands for `rootDir`. `global`
124
+ * is the config Jest is started with, whose `testTimeout` applies to every project in Jest 29.
125
+ */
126
+ private jestProject;
110
127
  /**
111
128
  * The projects of a Vitest config, the one the runner starts with or, in Vitest 5, a config another lists, whose name
112
129
  * then goes around the names of its projects.
113
130
  */
131
+ /**
132
+ * A project's settings over those of its `preset` when that is a local file, as `'../../jest.preset.js'`, which Jest
133
+ * finds from `rootDir`: the project's own settings win, as Jest merges them. A preset that is a package isn't read.
134
+ */
135
+ private withPreset;
114
136
  private vitestProjects;
115
137
  /**
116
138
  * A project written in `test.projects`, which inherits the root config as each version of Vitest decides, named