test-inventory 0.7.0 → 0.9.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 (67) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +16 -10
  3. package/dist/case-titles.d.ts +22 -0
  4. package/dist/case-titles.js +184 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +5 -5
  7. package/dist/config-dependencies.js +27 -3
  8. package/dist/config-runs.d.ts +117 -3
  9. package/dist/config-runs.js +1015 -19
  10. package/dist/config.d.ts +42 -1
  11. package/dist/config.js +287 -21
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +16 -1
  15. package/dist/extract.d.ts +2 -0
  16. package/dist/extract.js +89 -29
  17. package/dist/files.js +4 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +207 -7
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/bun.d.ts +42 -0
  24. package/dist/frameworks/bun.js +272 -0
  25. package/dist/frameworks/common.d.ts +3 -1
  26. package/dist/frameworks/common.js +18 -9
  27. package/dist/frameworks/cypress.d.ts +31 -0
  28. package/dist/frameworks/cypress.js +70 -0
  29. package/dist/frameworks/jasmine.d.ts +28 -0
  30. package/dist/frameworks/jasmine.js +218 -0
  31. package/dist/frameworks/jest.d.ts +46 -0
  32. package/dist/frameworks/jest.js +442 -0
  33. package/dist/frameworks/mocha.d.ts +50 -0
  34. package/dist/frameworks/mocha.js +306 -0
  35. package/dist/frameworks/node.d.ts +30 -0
  36. package/dist/frameworks/node.js +268 -0
  37. package/dist/frameworks/playwright.d.ts +13 -0
  38. package/dist/frameworks/playwright.js +57 -0
  39. package/dist/frameworks/test-functions.d.ts +194 -0
  40. package/dist/frameworks/test-functions.js +534 -0
  41. package/dist/frameworks/vitest.d.ts +15 -62
  42. package/dist/frameworks/vitest.js +69 -389
  43. package/dist/frameworks/webdriverio.d.ts +27 -0
  44. package/dist/frameworks/webdriverio.js +37 -0
  45. package/dist/imported-values.js +15 -1
  46. package/dist/model.d.ts +9 -2
  47. package/dist/node-scripts.d.ts +6 -0
  48. package/dist/node-scripts.js +85 -0
  49. package/dist/options.d.ts +16 -3
  50. package/dist/options.js +22 -3
  51. package/dist/parse.js +17 -2
  52. package/dist/records.d.ts +8 -2
  53. package/dist/records.js +94 -22
  54. package/dist/resolve.d.ts +21 -4
  55. package/dist/resolve.js +91 -52
  56. package/dist/scan-file.d.ts +8 -0
  57. package/dist/scan-file.js +21 -3
  58. package/dist/scan.js +62 -12
  59. package/dist/toml.d.ts +13 -0
  60. package/dist/toml.js +172 -0
  61. package/dist/types.d.ts +27 -11
  62. package/dist/values.d.ts +15 -0
  63. package/dist/values.js +120 -2
  64. package/dist/yaml.d.ts +12 -0
  65. package/dist/yaml.js +148 -0
  66. package/package.json +7 -6
  67. package/schema/v1.json +30 -8
@@ -1,9 +1,54 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globSync } from 'tinyglobby';
4
- const PACKAGES = { playwright: '@playwright/test', vitest: 'vitest' };
5
- /** Oldest supported version of each framework. */
6
- export const OLDEST = { playwright: [1, 42], vitest: [3, 0] };
4
+ import { nodeTestCommands } from "./node-scripts.js";
5
+ import { OptionsError } from "./options.js";
6
+ /** The package of each runner; `node:test` ships with Node.js. */
7
+ const PACKAGES = {
8
+ playwright: '@playwright/test',
9
+ vitest: 'vitest',
10
+ jest: 'jest',
11
+ mocha: 'mocha',
12
+ // Karma and Angular projects list only jasmine-core, which the jasmine command depends on.
13
+ jasmine: 'jasmine-core',
14
+ cypress: 'cypress',
15
+ webdriverio: '@wdio/cli',
16
+ };
17
+ /** The packages that give a framework's version, the first found. */
18
+ function versionPackages(framework) {
19
+ return framework === 'jasmine' ? [PACKAGES.jasmine, 'jasmine'] : [PACKAGES[framework]];
20
+ }
21
+ /**
22
+ * The packages that show a project runs a framework. For Jasmine, the jasmine command or Karma's adapter: jasmine-core
23
+ * alone is often left over in projects that moved to Jest, or comes with type packages.
24
+ */
25
+ function runnerPackages(framework) {
26
+ return framework === 'jasmine' ? ['jasmine', 'karma-jasmine'] : [PACKAGES[framework]];
27
+ }
28
+ /** Each framework's name in messages. */
29
+ export const FRAMEWORK_NAMES = {
30
+ playwright: 'Playwright',
31
+ vitest: 'Vitest',
32
+ jest: 'Jest',
33
+ mocha: 'Mocha',
34
+ jasmine: 'Jasmine',
35
+ cypress: 'Cypress',
36
+ webdriverio: 'WebdriverIO',
37
+ node: 'node:test',
38
+ bun: 'Bun',
39
+ };
40
+ /** Oldest supported version of each framework, as major, minor and for Bun, whose 1.2.23 changed how it runs tests, patch. */
41
+ export const OLDEST = {
42
+ playwright: [1, 42],
43
+ vitest: [3, 0],
44
+ jest: [29, 0],
45
+ mocha: [10, 0],
46
+ jasmine: [4, 0],
47
+ cypress: [13, 0],
48
+ webdriverio: [9, 0],
49
+ node: [22, 0],
50
+ bun: [1, 2, 23],
51
+ };
7
52
  /** A file's text, or null when it is missing or can't be read. */
8
53
  function readText(file) {
9
54
  try {
@@ -26,7 +71,7 @@ function escape(text) {
26
71
  return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
27
72
  }
28
73
  /** The root, then each folder above it, as a monorepo package's dependencies may be installed or locked higher up. */
29
- function folders(root) {
74
+ export function folders(root) {
30
75
  const list = [];
31
76
  for (let folder = path.resolve(root);; folder = path.dirname(folder)) {
32
77
  list.push(folder);
@@ -77,10 +122,16 @@ function declaredVersion(folder, name) {
77
122
  * below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
78
123
  */
79
124
  export function detectFrameworkVersion(root, framework, files) {
125
+ if (framework === 'node')
126
+ return nodeVersion(root);
127
+ if (framework === 'bun')
128
+ return bunVersion(root);
129
+ for (const name of versionPackages(framework)) {
130
+ const found = detectIn(root, folders(root), name);
131
+ if (found)
132
+ return found;
133
+ }
80
134
  const name = PACKAGES[framework];
81
- const found = detectIn(root, folders(root), name);
82
- if (found)
83
- return found;
84
135
  // In path order, then shallowest first; the sort is stable, so packages as deep stay in path order.
85
136
  const nested = globSync('**/package.json', { cwd: root, ignore: ['**/node_modules/**'] })
86
137
  .sort()
@@ -108,3 +159,152 @@ function detectIn(root, all, name) {
108
159
  }
109
160
  return null;
110
161
  }
162
+ /**
163
+ * The Node.js version a project pins, whose `node:test` runs its tests: the first of `.node-version`, `.nvmrc`, Volta's
164
+ * `node` and the newest version `engines.node` names, in the root, then in each folder above it. A pin without a
165
+ * minor, as `24`, or an alias, as `lts/*`, is a range or nothing.
166
+ */
167
+ function nodeVersion(root) {
168
+ const source = (folder, file) => path.relative(root, path.join(folder, file)).replaceAll('\\', '/');
169
+ for (const folder of folders(root)) {
170
+ for (const file of ['.node-version', '.nvmrc']) {
171
+ const pinned = readText(path.join(folder, file))?.trim() ?? '';
172
+ const found = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/.exec(pinned);
173
+ if (!found)
174
+ continue;
175
+ const [major, minor, patch] = [found[1], found[2], found[3]];
176
+ return {
177
+ version: `${major}.${minor ?? 0}.${patch ?? 0}`,
178
+ source: source(folder, file),
179
+ range: minor === undefined ? pinned : null,
180
+ };
181
+ }
182
+ const manifest = readJson(path.join(folder, 'package.json'));
183
+ const volta = manifest?.volta?.node;
184
+ if (typeof volta === 'string' && /^\d+\.\d+\.\d+$/.test(volta)) {
185
+ return { version: volta, source: source(folder, 'package.json'), range: null };
186
+ }
187
+ // `engines.node` is a floor: the newest version it names is the likeliest to run, and none older than node:test's.
188
+ const engines = manifest?.engines?.node;
189
+ const named = typeof engines === 'string' ? [...engines.matchAll(/\d+(?:\.\d+){0,2}/g)].map(versionParts) : [];
190
+ if (typeof engines === 'string' && named.length > 0) {
191
+ const newest = named.reduce((a, b) => (compareParts(b, a) > 0 ? b : a));
192
+ const floor = compareParts(newest, [...OLDEST.node, 0]) < 0 ? [...OLDEST.node, 0] : newest;
193
+ return { version: floor.join('.'), source: source(folder, 'package.json'), range: engines };
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ function versionParts(match) {
199
+ const [major, minor = 0, patch = 0] = match[0].split('.').map(Number);
200
+ return [major, minor, patch];
201
+ }
202
+ function compareParts(a, b) {
203
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
204
+ }
205
+ /**
206
+ * The Bun version a project pins: `.bun-version`, which setup-bun reads, or `packageManager` or `engines.bun` in
207
+ * package.json, in the root, then in each folder above it; else the version of `@types/bun` or `bun-types`, which Bun
208
+ * publishes with each release, read as other packages are.
209
+ */
210
+ function bunVersion(root) {
211
+ const source = (folder, file) => path.relative(root, path.join(folder, file)).replaceAll('\\', '/');
212
+ for (const folder of folders(root)) {
213
+ const pinned = readText(path.join(folder, '.bun-version'))?.trim() ?? '';
214
+ if (/^v?\d+\.\d+\.\d+$/.test(pinned)) {
215
+ return { version: pinned.replace(/^v/, ''), source: source(folder, '.bun-version'), range: null };
216
+ }
217
+ const manifest = readJson(path.join(folder, 'package.json'));
218
+ const manager = typeof manifest?.packageManager === 'string' ? /^bun@(\d+\.\d+\.\d+)/.exec(manifest.packageManager) : null;
219
+ if (manager)
220
+ return { version: manager[1], source: source(folder, 'package.json'), range: null };
221
+ const engines = manifest?.engines?.bun;
222
+ const lowest = typeof engines === 'string' ? /\d+\.\d+(\.\d+)?/.exec(engines) : null;
223
+ if (typeof engines === 'string' && lowest) {
224
+ const [major, minor, patch = '0'] = lowest[0].split('.');
225
+ return { version: `${major}.${minor}.${patch}`, source: source(folder, 'package.json'), range: engines };
226
+ }
227
+ }
228
+ for (const name of ['@types/bun', 'bun-types']) {
229
+ const found = detectIn(root, folders(root), name);
230
+ if (found)
231
+ return found;
232
+ }
233
+ return null;
234
+ }
235
+ /** Config file names of each runner. A Vite config alone doesn't count, as Vite apps without Vitest have one. */
236
+ const CONFIG_NAMES = {
237
+ playwright: /^playwright\.config\.[cm]?[jt]s$/,
238
+ vitest: /^vitest\.config\.[cm]?[jt]s$/,
239
+ jest: /^jest\.config\.([cm]?[jt]s|json)$/,
240
+ mocha: /^\.mocharc\.([cm]?js|ya?ml|jsonc?)$/,
241
+ // Matched against the path, not only the name: Jasmine's config is in spec/support.
242
+ jasmine: /(^|\/)spec\/support\/jasmine\.(m?js|json)$/,
243
+ cypress: /^cypress\.config\.[cm]?[jt]s$/,
244
+ webdriverio: /^wdio\.conf\.[cm]?[jt]s$/,
245
+ node: /^node\.config\.json$/,
246
+ bun: /^bunfig\.toml$/,
247
+ };
248
+ /**
249
+ * Whether a file is a runner's config. A `bunfig.toml` counts only with a `[test]` table, as projects that run another
250
+ * runner keep one for Bun's installs.
251
+ */
252
+ function isConfig(root, file, framework) {
253
+ if (!CONFIG_NAMES[framework].test(framework === 'jasmine' ? file : path.basename(file)))
254
+ return false;
255
+ // A file that can't be read gives null, which holds no table.
256
+ return framework !== 'bun' || /^\s*\[test\]/m.test(String(readText(path.join(root, file))));
257
+ }
258
+ /**
259
+ * Whether a package.json lists a runner, or for Jest or Mocha has a `jest` or `mocha` key, its config, or for
260
+ * `node:test` has a script that runs `node --test`.
261
+ */
262
+ function declares(manifest, framework) {
263
+ const json = readJson(manifest);
264
+ const fields = ['devDependencies', 'dependencies', 'peerDependencies', 'optionalDependencies'];
265
+ if (framework === 'bun') {
266
+ return Object.values(json?.scripts ?? {}).some((script) => typeof script === 'string' && /(^|[\s;&|(])bun\s+test(?=\s|$)/.test(script));
267
+ }
268
+ if (framework === 'node') {
269
+ return Object.values(json?.scripts ?? {}).some((script) => typeof script === 'string' && nodeTestCommands(script).length > 0);
270
+ }
271
+ return (fields.some((field) => runnerPackages(framework).some((name) => json?.[field]?.[name] !== undefined)) ||
272
+ ((framework === 'jest' || framework === 'mocha') && json?.[framework] !== undefined));
273
+ }
274
+ /**
275
+ * The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
276
+ * folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
277
+ * root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
278
+ * none of them or several, naming what it found.
279
+ */
280
+ export function detectFramework(root, ignore) {
281
+ const below = globSync([
282
+ '**/package.json',
283
+ '**/*.config.*',
284
+ '**/.mocharc.*',
285
+ '**/spec/support/jasmine.*',
286
+ '**/wdio.conf.*',
287
+ '**/node.config.json',
288
+ '**/bunfig.toml',
289
+ ], {
290
+ cwd: root,
291
+ ignore: ['**/node_modules/**', '**/.git/**', ...ignore],
292
+ });
293
+ const manifests = below.filter((file) => path.basename(file) === 'package.json').map((file) => path.join(root, file));
294
+ const above = folders(root)
295
+ .slice(1)
296
+ .map((folder) => path.join(folder, 'package.json'));
297
+ const all = ['playwright', 'vitest', 'jest', 'mocha', 'jasmine', 'cypress', 'webdriverio', 'node', 'bun'];
298
+ let found = all.filter((framework) => below.some((file) => isConfig(root, file, framework)) ||
299
+ manifests.some((manifest) => declares(manifest, framework)));
300
+ if (found.length === 0)
301
+ found = all.filter((framework) => above.some((manifest) => declares(manifest, framework)));
302
+ if (found.length === 1)
303
+ return found[0];
304
+ if (found.length === 0) {
305
+ throw new OptionsError('options.framework is needed: no package.json in the root, below it or above it lists @playwright/test, vitest, jest, mocha, jasmine, cypress or @wdio/cli or has a script that runs node --test or bun test, and no config of theirs is in the root or below.');
306
+ }
307
+ const names = found.map((framework) => FRAMEWORK_NAMES[framework]);
308
+ const values = found.map((framework) => `"${framework}"`);
309
+ throw new OptionsError(`options.framework is needed, as the project uses ${names.slice(0, -1).join(', ')} and ${names.at(-1)}: scan it once with each of ${values.join(', ')}.`);
310
+ }
@@ -19,6 +19,8 @@ export interface TestObjects {
19
19
  returnsTestObject(name: string): boolean;
20
20
  /** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
21
21
  reexports(source: string, imported: string, exported: Exported): boolean;
22
+ /** What the test object the module exports as `name` stands for, as `it.skip` stands for `it` with `skip`. */
23
+ exportedStart?(name: string): unknown;
22
24
  }
23
25
  /**
24
26
  * The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
@@ -54,6 +56,13 @@ export declare function allDeclarators(program: Program): VariableDeclarator[];
54
56
  export declare function moduleImports(program: Program, isFramework: (source: string) => boolean): Map<string, ModuleImport>;
55
57
  /** The module of `require('module')`, or null for any other expression. */
56
58
  export declare function requiredModule(expression: Expression): string | null;
59
+ /**
60
+ * What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
61
+ * `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
62
+ */
63
+ export declare function commonJsExport(program: Program, name: string): Expression | null;
64
+ /** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
65
+ export declare function commonJsNames(program: Program): string[];
57
66
  /** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
58
67
  export declare function moduleExportsValue(statement: Node): Expression | null;
59
68
  /** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
@@ -103,10 +112,15 @@ export declare function followModule(source: string, name: string, readModule: M
103
112
  interface Followed {
104
113
  exported: boolean;
105
114
  reason: string | null;
115
+ /** What the exported test object stands for, when the framework's test objects say. */
116
+ start?: unknown;
117
+ /** The module writes a function under the name, a helper that registers tests itself, which the walk follows. */
118
+ isHelper?: boolean;
106
119
  }
107
120
  /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
108
121
  export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
109
122
  exported: boolean;
110
123
  problem: Problem | null;
124
+ start: unknown;
111
125
  };
112
126
  export {};
@@ -1,4 +1,4 @@
1
- import { childNodes, isFunctionNode, propertyName, returnValues, unwrap } from "../ast.js";
1
+ import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, unwrap, } from "../ast.js";
2
2
  import { parseFile } from "../parse.js";
3
3
  import { snippet } from "../source.js";
4
4
  import { problem, text } from "./common.js";
@@ -125,6 +125,18 @@ export function moduleImports(program, isFramework) {
125
125
  imports.set(specifier.local.name, { source: statement.source.value, imported, node: specifier });
126
126
  }
127
127
  }
128
+ // `const { test } = require('./fixtures')` imports as `import { test } from './fixtures'` does.
129
+ for (const declarator of topLevelDeclarators(program)) {
130
+ const source = declarator.init ? requiredModule(declarator.init) : null;
131
+ if (source === null || !/^\.\.?\//.test(source) || declarator.id.type !== 'ObjectPattern')
132
+ continue;
133
+ for (const property of declarator.id.properties) {
134
+ const key = property.type === 'Property' ? propertyKey(property) : null;
135
+ if (key !== null && property.type === 'Property' && property.value.type === 'Identifier') {
136
+ imports.set(property.value.name, { source, imported: key, node: declarator });
137
+ }
138
+ }
139
+ }
128
140
  return imports;
129
141
  }
130
142
  /** The module of `require('module')`, or null for any other expression. */
@@ -135,6 +147,59 @@ export function requiredModule(expression) {
135
147
  const argument = node.arguments.at(0);
136
148
  return argument?.type === 'Literal' && typeof argument.value === 'string' ? argument.value : null;
137
149
  }
150
+ /**
151
+ * What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
152
+ * `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
153
+ */
154
+ export function commonJsExport(program, name) {
155
+ let found = null;
156
+ for (const statement of program.body) {
157
+ const whole = moduleExportsValue(statement);
158
+ if (whole) {
159
+ const object = unwrap(whole);
160
+ if (name === 'default')
161
+ found = whole;
162
+ else if (object.type === 'ObjectExpression') {
163
+ for (const property of object.properties) {
164
+ if (property.type === 'Property' && propertyKey(property) === name)
165
+ found = property.value;
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+ if (statement.type !== 'ExpressionStatement')
171
+ continue;
172
+ const expression = unwrap(statement.expression);
173
+ if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
174
+ continue;
175
+ if (propertyName(expression.left) !== name)
176
+ continue;
177
+ const target = unwrap(expression.left.object);
178
+ const isExports = target.type === 'Identifier' && target.name === 'exports';
179
+ const isModuleExports = target.type === 'MemberExpression' &&
180
+ target.object.type === 'Identifier' &&
181
+ target.object.name === 'module' &&
182
+ propertyName(target) === 'exports';
183
+ if (isExports || isModuleExports)
184
+ found = expression.right;
185
+ }
186
+ return found;
187
+ }
188
+ /** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
189
+ export function commonJsNames(program) {
190
+ const names = new Set();
191
+ for (const statement of program.body) {
192
+ if (statement.type !== 'ExpressionStatement')
193
+ continue;
194
+ const expression = unwrap(statement.expression);
195
+ if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
196
+ continue;
197
+ const name = propertyName(expression.left);
198
+ if (name !== null && commonJsExport(program, name) === expression.right)
199
+ names.add(name);
200
+ }
201
+ return [...names];
202
+ }
138
203
  /** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
139
204
  export function moduleExportsValue(statement) {
140
205
  if (statement.type !== 'ExpressionStatement')
@@ -187,6 +252,9 @@ export function exportsTestObject(program, name, objects, exported = 'test objec
187
252
  case 'specifier':
188
253
  return own.source === null ? local(own.local) : objects.reexports(own.source, own.local, exported);
189
254
  }
255
+ const commonJs = exported === 'test object' && name !== 'default' ? commonJsExport(program, name) : null;
256
+ if (commonJs)
257
+ return objects.isTestObject(commonJs);
190
258
  return program.body.some((statement) => {
191
259
  const source = starReexport(statement);
192
260
  return source !== null && objects.reexports(source, name, exported);
@@ -382,12 +450,21 @@ function followExports(module, name, framework, exported, within, followed) {
382
450
  return { exported: false, reason: 'could not be parsed' };
383
451
  const followFrom = (from, imported, kind) => followModule(from, imported, module.readModule, framework, kind, within, followed);
384
452
  const objects = framework.testObjects(program, (from, imported, kind = 'test object') => followFrom(from, imported, kind).exported);
385
- if (exportsTestObject(program, name, objects, exported))
386
- return { exported: true, reason: null };
453
+ if (exportsTestObject(program, name, objects, exported)) {
454
+ return { exported: true, reason: null, start: objects.exportedStart?.(name) };
455
+ }
456
+ // A function the module writes and exports is a helper that registers tests itself, which the walk follows.
457
+ if (exported === 'test object' && exportsFunction(program, name)) {
458
+ return { exported: false, reason: null, isHelper: true };
459
+ }
387
460
  const unrecognized = (kind) => `does not export ${kind === 'function' ? 'a function that returns a test object' : `a ${framework.name} test object`} that could be recognized`;
388
461
  const origin = importOrigin(program, name, framework.isSource, exported);
389
462
  if (origin) {
390
- const end = followFrom(origin.source, origin.imported, origin.exported).reason ?? unrecognized(origin.exported);
463
+ const followed = followFrom(origin.source, origin.imported, origin.exported);
464
+ // A helper function re-exported, as `import helper from './a'; export { helper }`, is no broken test object.
465
+ if (followed.isHelper && origin.how === 're-exports')
466
+ return followed;
467
+ const end = followed.reason ?? unrecognized(origin.exported);
391
468
  return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
392
469
  }
393
470
  if (importsFrom(program, framework.isSource))
@@ -397,6 +474,22 @@ function followExports(module, name, framework, exported, within, followed) {
397
474
  const call = exported === 'test object' ? creatingCall(program, name, source) : null;
398
475
  return { exported: false, reason: call ? `builds it with ${call}, which is not followed` : null };
399
476
  }
477
+ /** Whether a module writes the function it exports as `name`: declared, written in place, or held in a variable. */
478
+ function exportsFunction(program, name) {
479
+ const own = ownExport(program, name);
480
+ if (own?.kind === 'function')
481
+ return true;
482
+ if (own?.kind === 'default') {
483
+ const value = unwrap(own.value);
484
+ return value.type === 'FunctionDeclaration' || isFunctionNode(value);
485
+ }
486
+ const local = own?.kind === 'variable' ? name : own?.kind === 'specifier' && own.source === null ? own.local : null;
487
+ if (local === null)
488
+ return false;
489
+ const declared = program.body.some((statement) => statement.type === 'FunctionDeclaration' && statement.id?.name === local);
490
+ const declarator = topLevelDeclarators(program).find((item) => item.id.type === 'Identifier' && item.id.name === local);
491
+ return declared || isFunctionNode(declarator?.init ? unwrap(declarator.init) : null);
492
+ }
400
493
  /**
401
494
  * The call that creates what the module exports as `name`, a variable or its default export, as `defineBrokerTest()`;
402
495
  * null for other values.
@@ -412,9 +505,9 @@ function creatingCall(program, name, source) {
412
505
  }
413
506
  /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
414
507
  export function followImport(name, imported, readModule, file, framework) {
415
- const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
508
+ const { exported, reason, start } = followModule(imported.source, imported.imported, readModule, framework);
416
509
  const problemFound = reason
417
510
  ? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
418
511
  : null;
419
- return { exported, problem: problemFound };
512
+ return { exported, problem: problemFound, start };
420
513
  }
@@ -0,0 +1,42 @@
1
+ import type { CallExpression } from 'oxc-parser';
2
+ import type { ApiCall, CallContext, ModuleReader } from '../model.ts';
3
+ import type { ParsedFile } from '../parse.ts';
4
+ import type { HookCounts } from '../types.ts';
5
+ import { type FileContext, type Link } from './common.ts';
6
+ import { SuiteAdapter, type Kind } from './test-functions.ts';
7
+ /**
8
+ * Bun's `bun test`, from 1.2.23, which runs its tests the way 1.3 and 1.4 do. Its functions are globals in test files,
9
+ * and imports of `bun:test`, `@jest/globals` and `vitest` give them too. A chain holds its modes in any order, one of
10
+ * each group, and the conditions `if`, `skipIf`, `todoIf`, `failingIf`, `concurrentIf` and `serialIf` as often as
11
+ * it likes; options go second or third.
12
+ */
13
+ export declare class BunAdapter extends SuiteAdapter {
14
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
15
+ classify(call: CallExpression, context: CallContext): ApiCall | null;
16
+ protected isAliasLink(_kind: Kind, link: Link): boolean;
17
+ /** A hook; before Bun 1.3, one given more than its function fails the file. */
18
+ protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
19
+ private chainCall;
20
+ /**
21
+ * Whether Bun takes a chain: one of `skip`, `todo` and `failing`, one `only`, one of `concurrent` and `serial`, and
22
+ * one table, where a condition that holds counts as its mode, as `if(false)` counts as `skip`; the conditions and
23
+ * the table called and the modes not; and no `failing` on a describe. A condition the scan can't work out counts as
24
+ * not holding (checked with Bun 1.2.23, 1.3.14 and 1.4.4).
25
+ */
26
+ private isValid;
27
+ private condition;
28
+ private declaration;
29
+ /**
30
+ * The states of a chain, as Bun ranks them: `skip` over `todo` over `failing`. `if(condition)` skips when the
31
+ * condition is false; `skipIf`, `todoIf` and `failingIf` set theirs when it is true.
32
+ */
33
+ private states;
34
+ /** `concurrent` runs the test with others at once, and `serial` alone, also as `concurrentIf` and `serialIf` that hold. */
35
+ private mode;
36
+ /** A number as the timeout, or an object with `timeout`, `retry` and `repeats`. */
37
+ private options;
38
+ /** How many tests a table registers: one per item; null with a warning when the scan can't count them. */
39
+ private caseCount;
40
+ /** The cases of a table the scan can work out: an item that is an array is spread into the arguments, another is one. */
41
+ private tableCases;
42
+ }