@gate-forge/pack-playwright 0.1.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 (140) hide show
  1. package/LICENSE +202 -0
  2. package/bin/gateforge-witness.js +40 -0
  3. package/dist/attestation/proxy.d.ts +40 -0
  4. package/dist/attestation/proxy.d.ts.map +1 -0
  5. package/dist/attestation/proxy.js +78 -0
  6. package/dist/attestation/proxy.js.map +1 -0
  7. package/dist/constants.d.ts +130 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +138 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/discovery/adapters.d.ts +167 -0
  12. package/dist/discovery/adapters.d.ts.map +1 -0
  13. package/dist/discovery/adapters.js +276 -0
  14. package/dist/discovery/adapters.js.map +1 -0
  15. package/dist/discovery/discover.d.ts +57 -0
  16. package/dist/discovery/discover.d.ts.map +1 -0
  17. package/dist/discovery/discover.js +544 -0
  18. package/dist/discovery/discover.js.map +1 -0
  19. package/dist/discovery/index.d.ts +15 -0
  20. package/dist/discovery/index.d.ts.map +1 -0
  21. package/dist/discovery/index.js +15 -0
  22. package/dist/discovery/index.js.map +1 -0
  23. package/dist/discovery/inference.d.ts +63 -0
  24. package/dist/discovery/inference.d.ts.map +1 -0
  25. package/dist/discovery/inference.js +173 -0
  26. package/dist/discovery/inference.js.map +1 -0
  27. package/dist/discovery/pytest-adapter.d.ts +205 -0
  28. package/dist/discovery/pytest-adapter.d.ts.map +1 -0
  29. package/dist/discovery/pytest-adapter.js +548 -0
  30. package/dist/discovery/pytest-adapter.js.map +1 -0
  31. package/dist/discovery/reconcile.d.ts +106 -0
  32. package/dist/discovery/reconcile.d.ts.map +1 -0
  33. package/dist/discovery/reconcile.js +268 -0
  34. package/dist/discovery/reconcile.js.map +1 -0
  35. package/dist/discovery/runner-env.d.ts +80 -0
  36. package/dist/discovery/runner-env.d.ts.map +1 -0
  37. package/dist/discovery/runner-env.js +139 -0
  38. package/dist/discovery/runner-env.js.map +1 -0
  39. package/dist/discovery/static-discovery.d.ts +121 -0
  40. package/dist/discovery/static-discovery.d.ts.map +1 -0
  41. package/dist/discovery/static-discovery.js +1028 -0
  42. package/dist/discovery/static-discovery.js.map +1 -0
  43. package/dist/discovery/supervised-run.d.ts +110 -0
  44. package/dist/discovery/supervised-run.d.ts.map +1 -0
  45. package/dist/discovery/supervised-run.js +328 -0
  46. package/dist/discovery/supervised-run.js.map +1 -0
  47. package/dist/discovery/trusted-config.d.ts +103 -0
  48. package/dist/discovery/trusted-config.d.ts.map +1 -0
  49. package/dist/discovery/trusted-config.js +117 -0
  50. package/dist/discovery/trusted-config.js.map +1 -0
  51. package/dist/fixture/evidence.d.ts +191 -0
  52. package/dist/fixture/evidence.d.ts.map +1 -0
  53. package/dist/fixture/evidence.js +366 -0
  54. package/dist/fixture/evidence.js.map +1 -0
  55. package/dist/fixture/fixture.d.ts +37 -0
  56. package/dist/fixture/fixture.d.ts.map +1 -0
  57. package/dist/fixture/fixture.js +38 -0
  58. package/dist/fixture/fixture.js.map +1 -0
  59. package/dist/fixture/witness-client.d.ts +164 -0
  60. package/dist/fixture/witness-client.d.ts.map +1 -0
  61. package/dist/fixture/witness-client.js +253 -0
  62. package/dist/fixture/witness-client.js.map +1 -0
  63. package/dist/index.d.ts +64 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +55 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/json.d.ts +16 -0
  68. package/dist/json.d.ts.map +1 -0
  69. package/dist/json.js +21 -0
  70. package/dist/json.js.map +1 -0
  71. package/dist/reporter/ledger.d.ts +105 -0
  72. package/dist/reporter/ledger.d.ts.map +1 -0
  73. package/dist/reporter/ledger.js +197 -0
  74. package/dist/reporter/ledger.js.map +1 -0
  75. package/dist/reporter/reporter.cjs +105 -0
  76. package/dist/reporter/reporter.cjs.map +1 -0
  77. package/dist/reporter/reporter.d.cts +35 -0
  78. package/dist/reporter/reporter.d.cts.map +1 -0
  79. package/dist/reporter/reporter.d.ts +167 -0
  80. package/dist/reporter/reporter.d.ts.map +1 -0
  81. package/dist/reporter/reporter.js +673 -0
  82. package/dist/reporter/reporter.js.map +1 -0
  83. package/dist/setup.d.ts +49 -0
  84. package/dist/setup.d.ts.map +1 -0
  85. package/dist/setup.js +167 -0
  86. package/dist/setup.js.map +1 -0
  87. package/dist/supervisor/client.d.ts +73 -0
  88. package/dist/supervisor/client.d.ts.map +1 -0
  89. package/dist/supervisor/client.js +164 -0
  90. package/dist/supervisor/client.js.map +1 -0
  91. package/dist/supervisor/drain.d.ts +45 -0
  92. package/dist/supervisor/drain.d.ts.map +1 -0
  93. package/dist/supervisor/drain.js +235 -0
  94. package/dist/supervisor/drain.js.map +1 -0
  95. package/dist/supervisor/index.d.ts +16 -0
  96. package/dist/supervisor/index.d.ts.map +1 -0
  97. package/dist/supervisor/index.js +13 -0
  98. package/dist/supervisor/index.js.map +1 -0
  99. package/dist/supervisor/spool.d.ts +137 -0
  100. package/dist/supervisor/spool.d.ts.map +1 -0
  101. package/dist/supervisor/spool.js +263 -0
  102. package/dist/supervisor/spool.js.map +1 -0
  103. package/dist/surface.d.ts +134 -0
  104. package/dist/surface.d.ts.map +1 -0
  105. package/dist/surface.js +139 -0
  106. package/dist/surface.js.map +1 -0
  107. package/dist/witness/adapter-registry.d.ts +38 -0
  108. package/dist/witness/adapter-registry.d.ts.map +1 -0
  109. package/dist/witness/adapter-registry.js +158 -0
  110. package/dist/witness/adapter-registry.js.map +1 -0
  111. package/dist/witness/bin.d.ts +21 -0
  112. package/dist/witness/bin.d.ts.map +1 -0
  113. package/dist/witness/bin.js +172 -0
  114. package/dist/witness/bin.js.map +1 -0
  115. package/dist/witness/browser.d.ts +173 -0
  116. package/dist/witness/browser.d.ts.map +1 -0
  117. package/dist/witness/browser.js +580 -0
  118. package/dist/witness/browser.js.map +1 -0
  119. package/dist/witness/classifications.d.ts +42 -0
  120. package/dist/witness/classifications.d.ts.map +1 -0
  121. package/dist/witness/classifications.js +87 -0
  122. package/dist/witness/classifications.js.map +1 -0
  123. package/dist/witness/env-attestation.d.ts +98 -0
  124. package/dist/witness/env-attestation.d.ts.map +1 -0
  125. package/dist/witness/env-attestation.js +202 -0
  126. package/dist/witness/env-attestation.js.map +1 -0
  127. package/dist/witness/loopback-pins.d.ts +79 -0
  128. package/dist/witness/loopback-pins.d.ts.map +1 -0
  129. package/dist/witness/loopback-pins.js +244 -0
  130. package/dist/witness/loopback-pins.js.map +1 -0
  131. package/dist/witness/server.d.ts +34 -0
  132. package/dist/witness/server.d.ts.map +1 -0
  133. package/dist/witness/server.js +2539 -0
  134. package/dist/witness/server.js.map +1 -0
  135. package/dist/witness/types.d.ts +643 -0
  136. package/dist/witness/types.d.ts.map +1 -0
  137. package/dist/witness/types.js +2 -0
  138. package/dist/witness/types.js.map +1 -0
  139. package/package.json +64 -0
  140. package/python/gateforge_persistence_intents.py +100 -0
@@ -0,0 +1,1028 @@
1
+ /**
2
+ * Bounded static discovery of Playwright tests (plan 2026-09-13 phase 2
3
+ * item 3): a TypeScript-compiler-API scan of the configured test-file
4
+ * globs — the same AST-only pattern as `@gate-forge/pack-http`'s
5
+ * client-call scanner (pure analysis, no evaluation).
6
+ *
7
+ * The model is deliberately bounded and fail-closed:
8
+ *
9
+ * - **Test calls**: `test('title', fn)`, `test.skip/.only/.fixme(...)`,
10
+ * `test.each([...])(...)` — resolved through local aliases and the
11
+ * relative-import graph (`const t = test.extend({...})`,
12
+ * `t('title', ...)` in another file), plus this pack's own runner
13
+ * specifier `@gate-forge/pack-playwright` (its exported `test` is a
14
+ * playwright test function — the documented consumer import). Every
15
+ * other module-external binding stays unproven.
16
+ * - **Describes**: nested `test.describe('X', () => {...})` stacks build
17
+ * each case's full titlePath.
18
+ * - **Parameterization**: `test.each` and template-literal titles are
19
+ * recorded via `parameterIdentity` (with `${}` slots); a computed
20
+ * title that cannot be resolved statically becomes an UNRESOLVED row,
21
+ * never an omission.
22
+ * - **Budgets**: import traversal has a file-count budget and a depth
23
+ * bound; exceeding either records `traversal-budget-exceeded`
24
+ * unresolved rows instead of looping.
25
+ * - **Unresolvable wrappers**: a call through a name that cannot be
26
+ * proven to be a test function becomes an unresolved row with its
27
+ * call location — never "no tests".
28
+ * - **Parse errors**: every parser diagnostic is recorded with its
29
+ * location; a file that half-parses contributes both its rows and a
30
+ * parse error.
31
+ */
32
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
33
+ import { join, posix } from 'node:path';
34
+ import ts from 'typescript';
35
+ import { pathInScope } from '@gate-forge/core';
36
+ /** Default cap on files pulled in through import traversal. */
37
+ export const DEFAULT_MAX_TRAVERSED_FILES = 200;
38
+ /** Default bound on import-traversal depth (chain length). */
39
+ export const DEFAULT_MAX_IMPORT_DEPTH = 16;
40
+ /** Placeholder titlePath for unresolved calls with no readable title. */
41
+ export const UNRESOLVED_TITLE_PLACEHOLDER = '<unresolved-title>';
42
+ /** Directories never descended into (build output, deps, VCS state). */
43
+ const PRUNED_DIRS = new Set(['.git', 'node_modules', 'dist', 'test-results', 'playwright-report']);
44
+ /** File extensions the scanner parses (everything else is skipped). */
45
+ const PARSEABLE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
46
+ /** Playwright's exported test-function binding names. */
47
+ const TEST_BINDING_NAMES = new Set(['test', 'it']);
48
+ /**
49
+ * Test-structure names: a bare call through one of these is still a
50
+ * possible test registration even with no module-scope binding (an
51
+ * import-less global `test`/`it`/`describe`), so it stays a visible row.
52
+ * Any OTHER unbound bare identifier is an ordinary local call, not a
53
+ * test alias (consumer migration, E22: UI callbacks like
54
+ * `handleAction('export-csv', cb)` defined in function scope).
55
+ */
56
+ const TEST_STRUCTURE_NAMES = new Set(['test', 'it', 'describe']);
57
+ /**
58
+ * The gateforge pack's own module specifier (`packages/pack-playwright`):
59
+ * its exported `test` IS a playwright test function (`base.extend` over
60
+ * `playwright/test` — see `fixture/fixture.ts`, which documents this
61
+ * import as the only sanctioned runner). Binding it lets the static scan
62
+ * follow the documented consumer shape
63
+ * (`import { test as gateforgeTest } from '@gate-forge/pack-playwright'`)
64
+ * instead of emitting unresolvable rows for it. Every OTHER
65
+ * module-external import stays unresolved — fail-closed is unchanged.
66
+ */
67
+ export const GATEFORGE_PACK_SPECIFIER = '@gate-forge/pack-playwright';
68
+ /** Whether the specifier is the gateforge pack's runner module. */
69
+ function isPackSpecifier(specifier) {
70
+ return specifier === GATEFORGE_PACK_SPECIFIER;
71
+ }
72
+ /** Chain segments that modify a test/describe call without changing identity. */
73
+ const SUPPRESSION_SEGMENTS = new Set(['skip', 'only', 'fixme']);
74
+ /** Chain segments never part of a test call (hooks, fixtures, config). */
75
+ const NON_TEST_SEGMENTS = new Set([
76
+ 'beforeEach',
77
+ 'afterEach',
78
+ 'beforeAll',
79
+ 'afterAll',
80
+ 'use',
81
+ 'extend',
82
+ 'setTimeout',
83
+ 'expect',
84
+ 'annotate',
85
+ 'tag',
86
+ // Event-listener / interception shapes (`cy.on('tap', fn)`,
87
+ // `page.route('**/x', fn)`, `el.addEventListener('click', fn)`): a
88
+ // string first argument + callback is their ordinary shape, not a test
89
+ // declaration — no runner registers tests through these. Treating them
90
+ // as unprovable wrappers flooded the catalog with phantom unresolved
91
+ // rows when product code was scanned (consumer migration, E22).
92
+ 'on',
93
+ 'once',
94
+ 'addEventListener',
95
+ 'route',
96
+ // Module-mocking shapes (`vi.mock('mod', factory)`,
97
+ // `jest.mock('mod', factory)`): the vitest/jest module registry, never
98
+ // a test-case registration. The mock itself is already recorded as a
99
+ // file-level mock signal (findModuleMock); emitting an unresolved row
100
+ // per mocked module flooded setup files (consumer migration, E22).
101
+ 'mock',
102
+ // slowness modifier (`test.slow(...)`): suite/runner control like
103
+ // `use`/`setTimeout`, never a test declaration.
104
+ 'slow',
105
+ ]);
106
+ function languageKindFor(file) {
107
+ if (file.endsWith('.tsx'))
108
+ return ts.ScriptKind.TSX;
109
+ if (file.endsWith('.jsx'))
110
+ return ts.ScriptKind.JSX;
111
+ if (file.endsWith('.mjs') || file.endsWith('.cjs'))
112
+ return ts.ScriptKind.JS;
113
+ return ts.ScriptKind.TS;
114
+ }
115
+ function locationOf(file, source, node) {
116
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
117
+ return { file, line: line + 1, col: character };
118
+ }
119
+ /**
120
+ * Walks the repo (pruning VCS/deps/build dirs) and returns the sorted
121
+ * repo-relative posix files matching the include globs and no exclude
122
+ * glob. Pure filesystem listing — no content is read here.
123
+ */
124
+ function collectCandidateFiles(cwd, include, exclude) {
125
+ const found = [];
126
+ const walk = (dir, rel) => {
127
+ let names;
128
+ try {
129
+ names = readdirSync(dir);
130
+ }
131
+ catch {
132
+ return; // unreadable dir: invisible, never a scan failure of other files
133
+ }
134
+ for (const name of names.sort()) {
135
+ if (PRUNED_DIRS.has(name))
136
+ continue;
137
+ const abs = join(dir, name);
138
+ const relPath = rel === '' ? name : `${rel}/${name}`;
139
+ let stats;
140
+ try {
141
+ stats = statSync(abs);
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ if (stats.isDirectory()) {
147
+ walk(abs, relPath);
148
+ }
149
+ else if (PARSEABLE_EXTENSIONS.some((ext) => name.endsWith(ext)) &&
150
+ pathInScope(relPath, include) &&
151
+ !pathInScope(relPath, exclude)) {
152
+ found.push(relPath);
153
+ }
154
+ }
155
+ };
156
+ walk(cwd, '');
157
+ return found.sort();
158
+ }
159
+ /** The callee shape `{base, names}` of `a.b.c(...)`, else null. */
160
+ function calleeChain(expression) {
161
+ if (ts.isIdentifier(expression))
162
+ return { base: expression.text, names: [] };
163
+ if (ts.isPropertyAccessExpression(expression)) {
164
+ const inner = calleeChain(expression.expression);
165
+ if (inner === null)
166
+ return null;
167
+ return { base: inner.base, names: [...inner.names, expression.name.text] };
168
+ }
169
+ return null;
170
+ }
171
+ /**
172
+ * Whether a function body references test-structure names — a wrapper
173
+ * or factory mentioning `test`/`it`/`describe` can register cases and
174
+ * must stay visible as unresolvable; a body free of them is an ordinary
175
+ * helper (seed/step/UI callback) whose calls are never test rows.
176
+ * Conservative by construction: shadowing, parameters, and property
177
+ * names also block the plain classification (fail-visible, never
178
+ * fail-silent).
179
+ *
180
+ * Args:
181
+ * body: the function body (or whole arrow/function expression) to scan.
182
+ *
183
+ * Returns:
184
+ * True when any `test`/`it`/`describe` identifier occurs in the subtree.
185
+ */
186
+ function bodyReferencesTest(body) {
187
+ let found = false;
188
+ const visit = (node) => {
189
+ if (found)
190
+ return;
191
+ if (ts.isIdentifier(node) && TEST_STRUCTURE_NAMES.has(node.text)) {
192
+ found = true;
193
+ return;
194
+ }
195
+ ts.forEachChild(node, visit);
196
+ };
197
+ visit(body);
198
+ return found;
199
+ }
200
+ /** Reads a file's text; unreadable content is a parse error row. */
201
+ function readText(state, file) {
202
+ try {
203
+ return readFileSync(join(state.cwd, file), 'utf8');
204
+ }
205
+ catch (error) {
206
+ state.result.parseErrors.push({
207
+ file,
208
+ message: `cannot read file: ${error.message.split('\n')[0] ?? 'read error'}`,
209
+ location: { file, line: 1, col: 0 },
210
+ });
211
+ return null;
212
+ }
213
+ }
214
+ /**
215
+ * Models one file's module-scope bindings. Files are modeled on demand:
216
+ * seeded candidates first, import targets pulled from disk under the
217
+ * traversal budget.
218
+ */
219
+ function modelOf(state, cwd, file, pulled) {
220
+ const cached = state.models.get(file);
221
+ if (cached !== undefined)
222
+ return cached;
223
+ if (pulled) {
224
+ if (state.traversed.has(file))
225
+ return null;
226
+ if (state.traversed.size >= state.maxTraversedFiles) {
227
+ state.result.budgetExceeded = true;
228
+ return null;
229
+ }
230
+ state.traversed.add(file);
231
+ if (!existsSync(join(cwd, file)))
232
+ return null;
233
+ }
234
+ const text = readText(state, file);
235
+ if (text === null)
236
+ return null;
237
+ if (!state.result.scannedFiles.includes(file))
238
+ state.result.scannedFiles.push(file);
239
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, languageKindFor(file));
240
+ const parseDiagnostics = source
241
+ .parseDiagnostics;
242
+ for (const diagnostic of parseDiagnostics ?? []) {
243
+ const { line, character } = source.getLineAndCharacterOfPosition(diagnostic.start);
244
+ state.result.parseErrors.push({
245
+ file,
246
+ message: String(diagnostic.messageText).split('\n')[0] ?? 'parse error',
247
+ location: { file, line: line + 1, col: character },
248
+ });
249
+ }
250
+ const model = { file, source, bindings: new Map(), exports: new Map() };
251
+ state.models.set(file, model);
252
+ modelModuleScope(state, cwd, model, source);
253
+ return model;
254
+ }
255
+ /** Collects module-scope imports, `test.extend` aliases, and exports. */
256
+ function modelModuleScope(state, cwd, model, source) {
257
+ const isModuleScope = (node) => {
258
+ let current = node.parent;
259
+ while (current !== undefined) {
260
+ if (ts.isFunctionDeclaration(current) ||
261
+ ts.isFunctionExpression(current) ||
262
+ ts.isArrowFunction(current) ||
263
+ ts.isMethodDeclaration(current) ||
264
+ ts.isBlock(current)) {
265
+ return false;
266
+ }
267
+ current = current.parent;
268
+ }
269
+ return true;
270
+ };
271
+ const visit = (node) => {
272
+ // import { test [as t] } from '@playwright/test' | './helpers'
273
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && isModuleScope(node)) {
274
+ const clause = node.importClause;
275
+ const specifier = node.moduleSpecifier.text;
276
+ const isRelative = specifier.startsWith('./') || specifier.startsWith('../');
277
+ const markExternal = (local, imported) => {
278
+ // A named import from outside the scanned set (package, builtin,
279
+ // alias): an attempted alias, not an ordinary local — later
280
+ // test-shaped calls through it stay visible rows. Relative
281
+ // targets that resolve nowhere keep the same marker plus their
282
+ // own unresolved-import row (fail-visible twice, never silent).
283
+ if (isRelative) {
284
+ const target = resolveSpecifier(state, cwd, model.file, specifier);
285
+ if (target !== null) {
286
+ model.bindings.set(local, { kind: 'import', target, importedName: imported });
287
+ return;
288
+ }
289
+ model.bindings.set(local, { kind: 'import-broken', importedName: imported });
290
+ state.result.unresolved.push({
291
+ code: 'unresolved-import',
292
+ detail: `import '${imported}' from '${specifier}' does not resolve inside the scanned set`,
293
+ file: model.file,
294
+ titlePath: [UNRESOLVED_TITLE_PLACEHOLDER],
295
+ location: locationOf(model.file, source, node),
296
+ });
297
+ }
298
+ else {
299
+ model.bindings.set(local, { kind: 'external', importedName: imported });
300
+ }
301
+ };
302
+ if (clause?.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
303
+ for (const element of clause.namedBindings.elements) {
304
+ const imported = element.propertyName?.text ?? element.name.text;
305
+ const local = element.name.text;
306
+ if (TEST_BINDING_NAMES.has(imported) && isTestModuleSpecifier(specifier)) {
307
+ model.bindings.set(local, { kind: 'test' });
308
+ }
309
+ else {
310
+ markExternal(local, imported);
311
+ }
312
+ }
313
+ }
314
+ // import * as pw from '@playwright/test' | './helpers' — the module
315
+ // object (test-module when the specifier is the test module).
316
+ if (clause?.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) {
317
+ const local = clause.namedBindings.name.text;
318
+ if (isTestModuleSpecifier(specifier)) {
319
+ model.bindings.set(local, { kind: 'testmodule' });
320
+ }
321
+ else {
322
+ markExternal(local, '*');
323
+ }
324
+ }
325
+ // import foo from './helpers' — default import through the target's
326
+ // exports (or a visible marker when it cannot resolve).
327
+ if (clause?.name !== undefined) {
328
+ markExternal(clause.name.text, 'default');
329
+ }
330
+ }
331
+ // export { x [as y] } / export { x } from './m'
332
+ if (ts.isExportDeclaration(node) && isModuleScope(node)) {
333
+ const specifier = node.moduleSpecifier;
334
+ if (specifier !== undefined && ts.isStringLiteral(specifier)) {
335
+ if (node.exportClause !== undefined &&
336
+ ts.isNamedExports(node.exportClause) &&
337
+ isPackSpecifier(specifier.text)) {
338
+ // Bare re-export of the pack's own surface (the sanctioned
339
+ // local runner-module pattern, e.g. the consumer's
340
+ // `helpers.js`: `export { test } from '@gate-forge/pack-playwright'`).
341
+ for (const element of node.exportClause.elements) {
342
+ const imported = element.propertyName?.text ?? element.name.text;
343
+ if (TEST_BINDING_NAMES.has(imported)) {
344
+ model.exports.set(element.name.text, { packTest: true });
345
+ }
346
+ }
347
+ }
348
+ else {
349
+ const target = specifier.text.startsWith('./') || specifier.text.startsWith('../')
350
+ ? resolveSpecifier(state, cwd, model.file, specifier.text)
351
+ : null;
352
+ if (target !== null && node.exportClause !== undefined && ts.isNamedExports(node.exportClause)) {
353
+ for (const element of node.exportClause.elements) {
354
+ model.exports.set(element.name.text, {
355
+ targetFile: target,
356
+ importedName: element.propertyName?.text ?? element.name.text,
357
+ });
358
+ }
359
+ }
360
+ }
361
+ }
362
+ else if (node.exportClause !== undefined && ts.isNamedExports(node.exportClause)) {
363
+ for (const element of node.exportClause.elements) {
364
+ model.exports.set(element.name.text, { local: element.propertyName?.text ?? element.name.text });
365
+ }
366
+ }
367
+ }
368
+ // module-scope const aliases: `const t = test.extend({...})`, `const t = base`, wrappers
369
+ if (ts.isVariableStatement(node) && isModuleScope(node)) {
370
+ for (const declaration of node.declarationList.declarations) {
371
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined)
372
+ continue;
373
+ const name = declaration.name.text;
374
+ const initializer = declaration.initializer;
375
+ if (ts.isIdentifier(initializer)) {
376
+ model.bindings.set(name, { kind: 'alias', target: initializer.text });
377
+ }
378
+ else if (ts.isCallExpression(initializer) &&
379
+ ts.isPropertyAccessExpression(initializer.expression) &&
380
+ initializer.expression.name.text === 'extend') {
381
+ const extended = initializer.expression.expression;
382
+ if (ts.isIdentifier(extended)) {
383
+ model.bindings.set(name, { kind: 'alias', target: extended.text });
384
+ }
385
+ else if (ts.isPropertyAccessExpression(extended) &&
386
+ (extended.name.text === 'test' || extended.name.text === 'it') &&
387
+ ts.isIdentifier(extended.expression)) {
388
+ // Member-extend over a module object
389
+ // (`const test = base.test.extend({...})` with
390
+ // `const base = require('@playwright/test')`): the
391
+ // consumer-local harness pattern — resolves through the
392
+ // module binding (consumer migration, E22).
393
+ model.bindings.set(name, { kind: 'alias', target: extended.expression.text });
394
+ }
395
+ else {
396
+ model.bindings.set(name, { kind: 'unresolvable' });
397
+ }
398
+ }
399
+ else if (ts.isCallExpression(initializer) &&
400
+ ts.isIdentifier(initializer.expression) &&
401
+ initializer.expression.text === 'require' &&
402
+ initializer.arguments.length > 0 &&
403
+ initializer.arguments[0] !== undefined &&
404
+ ts.isStringLiteral(initializer.arguments[0]) &&
405
+ isTestModuleSpecifier(initializer.arguments[0].text)) {
406
+ // `const base = require('@playwright/test')`: the module object
407
+ // whose `.test` is the test function. Bare requires of any other
408
+ // specifier bind nothing (ordinary library calls, not aliases).
409
+ model.bindings.set(name, { kind: 'testmodule' });
410
+ }
411
+ else if ((ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) &&
412
+ !TEST_STRUCTURE_NAMES.has(name) &&
413
+ !bodyReferencesTest(initializer)) {
414
+ // A locally-defined plain function with no test-structure
415
+ // reference in its body (seed/step/UI helper): calls through it
416
+ // are ordinary calls, never test registrations (consumer
417
+ // migration, E22). Anything test-referencing stays unresolvable
418
+ // (a possible factory — fail-visible).
419
+ model.bindings.set(name, { kind: 'plain' });
420
+ }
421
+ else {
422
+ // `const t = makeTest()` and friends: a wrapper the scanner
423
+ // cannot prove — calls through it stay visible as unresolved.
424
+ model.bindings.set(name, { kind: 'unresolvable' });
425
+ }
426
+ if ((node.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
427
+ model.exports.set(name, { local: name });
428
+ }
429
+ }
430
+ }
431
+ // CJS destructure: `const { test } = require('@playwright/test')` or
432
+ // `const { test } = require('./helpers')` — same bindings as ESM.
433
+ if (ts.isVariableStatement(node) &&
434
+ isModuleScope(node) &&
435
+ !ts.isImportDeclaration(node)) {
436
+ for (const declaration of node.declarationList.declarations) {
437
+ if (!ts.isObjectBindingPattern(declaration.name))
438
+ continue;
439
+ const initializer = declaration.initializer;
440
+ if (initializer === undefined || !ts.isCallExpression(initializer))
441
+ continue;
442
+ if (!ts.isIdentifier(initializer.expression) || initializer.expression.text !== 'require')
443
+ continue;
444
+ const requireArgument = initializer.arguments[0];
445
+ if (requireArgument === undefined || !ts.isStringLiteral(requireArgument))
446
+ continue;
447
+ const specifier = requireArgument.text;
448
+ for (const element of declaration.name.elements) {
449
+ if (!ts.isBindingElement(element) || !ts.isIdentifier(element.name))
450
+ continue;
451
+ const imported = (element.propertyName !== undefined && ts.isIdentifier(element.propertyName)
452
+ ? element.propertyName.text
453
+ : element.name.text);
454
+ const local = element.name.text;
455
+ if (TEST_BINDING_NAMES.has(imported) && isTestModuleSpecifier(specifier)) {
456
+ model.bindings.set(local, { kind: 'test' });
457
+ }
458
+ else if (specifier.startsWith('./') || specifier.startsWith('../')) {
459
+ const target = resolveSpecifier(state, cwd, model.file, specifier);
460
+ if (target !== null) {
461
+ model.bindings.set(local, { kind: 'import', target, importedName: imported });
462
+ }
463
+ else {
464
+ // Same failed-binding marker as the ESM path above: the
465
+ // name was meant as an alias, so later test-shaped calls
466
+ // through it stay visible rows.
467
+ model.bindings.set(local, { kind: 'import-broken', importedName: imported });
468
+ }
469
+ }
470
+ }
471
+ }
472
+ }
473
+ // Module-scope function declarations: a plain `function seed(...)`
474
+ // with no test-structure reference in its body is an ordinary helper
475
+ // (consumer migration, E22: `withStepTimeout`/`runStep` step wrappers).
476
+ // Test-referencing bodies stay unmodeled (a possible factory), and
477
+ // `test`/`it`/`describe`-named declarations stay unmodeled (a possible
478
+ // global registration) — both fail-visible, never fail-silent.
479
+ if (ts.isFunctionDeclaration(node) &&
480
+ isModuleScope(node) &&
481
+ node.name !== undefined &&
482
+ ts.isIdentifier(node.name) &&
483
+ node.body !== undefined) {
484
+ const name = node.name.text;
485
+ if (!TEST_STRUCTURE_NAMES.has(name) && !bodyReferencesTest(node.body)) {
486
+ model.bindings.set(name, { kind: 'plain' });
487
+ }
488
+ if ((node.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
489
+ model.exports.set(name, { local: name });
490
+ }
491
+ }
492
+ // CJS exports: `module.exports = { test, seed: runSeed };` — export
493
+ // mappings so `require('./helpers')` destructuring resolves through
494
+ // the defining file (consumer-local harness pattern, E22).
495
+ if (ts.isExpressionStatement(node) && isModuleScope(node)) {
496
+ const expr = node.expression;
497
+ if (ts.isBinaryExpression(expr) &&
498
+ expr.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
499
+ ts.isPropertyAccessExpression(expr.left) &&
500
+ ts.isIdentifier(expr.left.expression) &&
501
+ expr.left.expression.text === 'module' &&
502
+ expr.left.name.text === 'exports' &&
503
+ ts.isObjectLiteralExpression(expr.right)) {
504
+ for (const prop of expr.right.properties) {
505
+ if (ts.isShorthandPropertyAssignment(prop)) {
506
+ model.exports.set(prop.name.text, { local: prop.name.text });
507
+ }
508
+ else if (ts.isPropertyAssignment(prop) &&
509
+ ts.isIdentifier(prop.name) &&
510
+ ts.isIdentifier(prop.initializer)) {
511
+ model.exports.set(prop.name.text, { local: prop.initializer.text });
512
+ }
513
+ }
514
+ }
515
+ }
516
+ ts.forEachChild(node, visit);
517
+ };
518
+ visit(source);
519
+ }
520
+ /** Whether the specifier is playwright's own test module. */
521
+ function isPlaywrightSpecifier(specifier) {
522
+ return specifier === '@playwright/test' || specifier === 'playwright/test' || specifier === 'playwright';
523
+ }
524
+ /**
525
+ * Whether a named import from this specifier can bind a playwright test
526
+ * function: playwright's own test module, or the gateforge pack (whose
527
+ * exported `test` is a `base.extend` over playwright's — see
528
+ * {@link GATEFORGE_PACK_SPECIFIER}).
529
+ */
530
+ function isTestModuleSpecifier(specifier) {
531
+ return isPlaywrightSpecifier(specifier) || isPackSpecifier(specifier);
532
+ }
533
+ /** Resolves a relative specifier against the repo (candidate extensions). */
534
+ function resolveSpecifier(state, cwd, fromFile, specifier) {
535
+ const base = posix.dirname(fromFile);
536
+ const joined = posix.normalize(posix.join(base, specifier));
537
+ const candidates = [
538
+ joined,
539
+ ...PARSEABLE_EXTENSIONS.map((ext) => `${joined}${ext}`),
540
+ ...PARSEABLE_EXTENSIONS.map((ext) => `${joined}/index${ext}`),
541
+ ];
542
+ for (const candidate of candidates) {
543
+ if (state.seeded.has(candidate) || state.traversed.has(candidate) || existsSync(join(cwd, candidate))) {
544
+ return candidate;
545
+ }
546
+ }
547
+ return null;
548
+ }
549
+ /**
550
+ * Resolves whether `name` in `file` binds to a playwright test function,
551
+ * through aliases and relative imports, bounded by depth + file budget.
552
+ * Cycle-guarded via `state.resolving`.
553
+ */
554
+ function resolveTestAlias(state, cwd, file, name, depth) {
555
+ if (depth > state.maxImportDepth) {
556
+ state.result.budgetExceeded = true;
557
+ return 'budget';
558
+ }
559
+ const key = `${file}::${name}`;
560
+ if (state.resolving.has(key))
561
+ return 'unknown'; // alias cycle: not provable
562
+ const model = modelOf(state, cwd, file, !state.seeded.has(file));
563
+ if (model === null) {
564
+ if (state.result.budgetExceeded && !state.seeded.has(file) && !state.traversed.has(file)) {
565
+ return 'budget';
566
+ }
567
+ return 'unknown';
568
+ }
569
+ const binding = model.bindings.get(name);
570
+ if (binding !== undefined) {
571
+ if (binding.kind === 'test')
572
+ return 'test';
573
+ if (binding.kind === 'testmodule')
574
+ return 'testmodule';
575
+ if (binding.kind === 'plain')
576
+ return 'not-a-test';
577
+ if (binding.kind === 'import-broken')
578
+ return 'unknown';
579
+ if (binding.kind === 'external')
580
+ return 'unknown';
581
+ if (binding.kind === 'unresolvable')
582
+ return 'wrapper-unresolvable';
583
+ if (binding.kind === 'alias' && binding.target !== undefined) {
584
+ state.resolving.add(key);
585
+ const resolved = resolveTestAlias(state, cwd, file, binding.target, depth + 1);
586
+ state.resolving.delete(key);
587
+ // An alias chain rooted in the required test module object
588
+ // (`base.test.extend(...)` over `require('@playwright/test')`) is
589
+ // the test function itself.
590
+ if (resolved === 'testmodule')
591
+ return 'test';
592
+ return resolved;
593
+ }
594
+ if (binding.kind === 'import' && binding.target !== undefined && binding.importedName !== undefined) {
595
+ state.resolving.add(key);
596
+ const resolved = resolveExportedName(state, cwd, binding.target, binding.importedName, depth + 1);
597
+ state.resolving.delete(key);
598
+ return resolved;
599
+ }
600
+ }
601
+ return 'unknown';
602
+ }
603
+ /** Resolves a name through the target file's exports, then its bindings. */
604
+ function resolveExportedName(state, cwd, file, name, depth) {
605
+ if (depth > state.maxImportDepth) {
606
+ state.result.budgetExceeded = true;
607
+ return 'budget';
608
+ }
609
+ const model = modelOf(state, cwd, file, !state.seeded.has(file));
610
+ if (model === null) {
611
+ return state.result.budgetExceeded && !state.seeded.has(file) && !state.traversed.has(file)
612
+ ? 'budget'
613
+ : 'unknown';
614
+ }
615
+ const exported = model.exports.get(name);
616
+ if (exported !== undefined) {
617
+ if ('local' in exported)
618
+ return resolveTestAlias(state, cwd, file, exported.local, depth + 1);
619
+ if ('packTest' in exported)
620
+ return 'test';
621
+ state.resolving.add(`${file}::${name}`);
622
+ const resolved = resolveExportedName(state, cwd, exported.targetFile, exported.importedName, depth + 1);
623
+ state.resolving.delete(`${file}::${name}`);
624
+ return resolved;
625
+ }
626
+ return resolveTestAlias(state, cwd, file, name, depth + 1);
627
+ }
628
+ /** Extracts the title text (with `${}` slots) or null when computed. */
629
+ function titleOf(argument) {
630
+ if (argument === undefined)
631
+ return null;
632
+ if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
633
+ return { title: argument.text, parameterized: false };
634
+ }
635
+ if (ts.isTemplateExpression(argument)) {
636
+ let text = argument.head.text;
637
+ for (const span of argument.templateSpans) {
638
+ text += '${}';
639
+ text += span.literal.text;
640
+ }
641
+ return { title: text, parameterized: true };
642
+ }
643
+ return null;
644
+ }
645
+ /** Fixture names in the callback's first parameter (destructured or bare). */
646
+ function signatureParamsOf(callback) {
647
+ if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))
648
+ return [];
649
+ const first = callback.parameters[0];
650
+ if (first === undefined)
651
+ return [];
652
+ if (ts.isObjectBindingPattern(first.name)) {
653
+ const names = [];
654
+ for (const element of first.name.elements) {
655
+ if (ts.isBindingElement(element) && ts.isIdentifier(element.name))
656
+ names.push(element.name.text);
657
+ }
658
+ return names;
659
+ }
660
+ if (ts.isIdentifier(first.name))
661
+ return [first.name.text];
662
+ return [];
663
+ }
664
+ /**
665
+ * Signature parameter names that prove a real browser is in play.
666
+ * `evidence` is this pack's trusted-evidence fixture — it is built on a
667
+ * live `page` (see `fixture/fixture.ts`), so a test declaring it runs a
668
+ * real browser journey.
669
+ */
670
+ export const BROWSER_FIXTURE_PARAMS = new Set(['page', 'browser', 'context', 'browserName', 'evidence']);
671
+ const HTTP_CLIENT_CALLEES = new Set(['fetch', 'axios']);
672
+ /** Whether the subtree contains `<x>.route(` — playwright interception. */
673
+ function findPageRoute(node, source, file) {
674
+ let found = null;
675
+ const visit = (current) => {
676
+ if (found !== null)
677
+ return;
678
+ if (ts.isCallExpression(current) &&
679
+ ts.isPropertyAccessExpression(current.expression) &&
680
+ current.expression.name.text === 'route' &&
681
+ ts.isIdentifier(current.expression.expression)) {
682
+ found = locationOf(file, source, current);
683
+ return;
684
+ }
685
+ ts.forEachChild(current, visit);
686
+ };
687
+ visit(node);
688
+ return found;
689
+ }
690
+ /** Whether the subtree calls fetch/axios (the app's HTTP boundary). */
691
+ function findHttpClientCall(node, source, file) {
692
+ let found = null;
693
+ const visit = (current) => {
694
+ if (found !== null)
695
+ return;
696
+ if (ts.isCallExpression(current)) {
697
+ const chain = calleeChain(current.expression);
698
+ if (chain !== null && HTTP_CLIENT_CALLEES.has(chain.base)) {
699
+ found = locationOf(file, source, current);
700
+ return;
701
+ }
702
+ }
703
+ ts.forEachChild(current, visit);
704
+ };
705
+ visit(node);
706
+ return found;
707
+ }
708
+ /** Whether the file contains a `vi.mock(...)` / `jest.mock(...)` call. */
709
+ function findModuleMock(source, file) {
710
+ let found = null;
711
+ const visit = (node) => {
712
+ if (found !== null)
713
+ return;
714
+ if (ts.isCallExpression(node) &&
715
+ ts.isPropertyAccessExpression(node.expression) &&
716
+ (node.expression.name.text === 'mock') &&
717
+ ts.isIdentifier(node.expression.expression) &&
718
+ (node.expression.expression.text === 'vi' || node.expression.expression.text === 'jest')) {
719
+ found = locationOf(file, source, node);
720
+ return;
721
+ }
722
+ ts.forEachChild(node, visit);
723
+ };
724
+ visit(source);
725
+ return found;
726
+ }
727
+ /**
728
+ * Runs the bounded static scan over the configured globs. Every value is
729
+ * derived from ASTs; the only I/O is reading candidate + import-target
730
+ * files under {@link ScanBudget}.
731
+ *
732
+ * Args:
733
+ * options: cwd, include/exclude globs, and optional budgets.
734
+ *
735
+ * Returns:
736
+ * StaticScanResult: entries, unresolved rows, parse errors, scanned
737
+ * files, and the budget flag. Never throws for scanner-detectable
738
+ * problems — those are rows (fail closed as data, not silence).
739
+ */
740
+ export function scanTestFiles(options) {
741
+ const state = {
742
+ cwd: options.cwd,
743
+ result: { entries: [], unresolved: [], parseErrors: [], scannedFiles: [], budgetExceeded: false },
744
+ models: new Map(),
745
+ seeded: new Set(),
746
+ traversed: new Set(),
747
+ resolving: new Set(),
748
+ maxTraversedFiles: options.budget?.maxTraversedFiles ?? DEFAULT_MAX_TRAVERSED_FILES,
749
+ maxImportDepth: options.budget?.maxImportDepth ?? DEFAULT_MAX_IMPORT_DEPTH,
750
+ };
751
+ const seededFiles = collectCandidateFiles(options.cwd, options.include, options.exclude);
752
+ for (const file of seededFiles)
753
+ state.seeded.add(file);
754
+ for (const file of seededFiles) {
755
+ const model = modelOf(state, options.cwd, file, false);
756
+ if (model === null)
757
+ continue;
758
+ const fileHttpClient = findHttpClientCall(model.source, model.source, file);
759
+ const fileMock = findModuleMock(model.source, file);
760
+ scanFileForTests(state, options.cwd, model, fileHttpClient, fileMock);
761
+ }
762
+ state.result.entries.sort((a, b) => compareEntry(a, b));
763
+ state.result.unresolved.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
764
+ a.location.line - b.location.line ||
765
+ a.location.col - b.location.col);
766
+ state.result.parseErrors.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.location.line - b.location.line);
767
+ state.result.scannedFiles.sort();
768
+ return state.result;
769
+ }
770
+ /** Deterministic entry order: file, then titlePath, then line. */
771
+ function compareEntry(a, b) {
772
+ return ((a.file < b.file ? -1 : a.file > b.file ? 1 : 0) ||
773
+ (a.titlePath.join('>') < b.titlePath.join('>') ? -1 : a.titlePath.join('>') > b.titlePath.join('>') ? 1 : 0) ||
774
+ a.location.line - b.location.line);
775
+ }
776
+ /** Visits one modeled file, harvesting describe stacks and test calls. */
777
+ function scanFileForTests(state, cwd, model, fileHttpClient, fileMock) {
778
+ const { file, source } = model;
779
+ const visit = (node, describeStack, enclosing) => {
780
+ if (!ts.isCallExpression(node)) {
781
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
782
+ return;
783
+ }
784
+ const chain = calleeChain(node.expression);
785
+ if (chain === null) {
786
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
787
+ return;
788
+ }
789
+ const location = locationOf(file, source, node);
790
+ const resolution = resolveTestAlias(state, cwd, file, chain.base, 0);
791
+ const names = chain.names;
792
+ const isDescribe = names.includes('describe');
793
+ const isExtend = names.includes('extend');
794
+ const lifecycle = names.some((name) => NON_TEST_SEGMENTS.has(name));
795
+ const suppression = names.filter((name) => SUPPRESSION_SEGMENTS.has(name));
796
+ const eachCall = names.includes('each');
797
+ const titleArgument = node.arguments[0];
798
+ const callback = node.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
799
+ // `pw.test(...)` / `base.test(...)` where pw/base is the required or
800
+ // namespaced test module object: the test function itself (not a
801
+ // wrapper) — grades exactly like a direct `test(...)` registration.
802
+ const effectiveResolution = resolution === 'testmodule' && !isExtend && !lifecycle && !isDescribe && (names[0] === 'test' || names[0] === 'it')
803
+ ? 'test'
804
+ : resolution;
805
+ // Zero-arg conditional suppression INSIDE a test body: test.skip() / test.fixme()
806
+ if (effectiveResolution === 'test' &&
807
+ suppression.length > 0 &&
808
+ node.arguments.length === 0 &&
809
+ enclosing !== null) {
810
+ enclosing.signals.push({ kind: suppression[0], detail: `${chain.base}.${suppression[0]}() call`, location });
811
+ return;
812
+ }
813
+ // Conditional-skip form test.skip(condition, 'reason') inside a body.
814
+ if (effectiveResolution === 'test' &&
815
+ suppression.length > 0 &&
816
+ node.arguments.length === 2 &&
817
+ callback === undefined &&
818
+ enclosing !== null) {
819
+ enclosing.signals.push({ kind: suppression[0], detail: `${chain.base}.${suppression[0]}(condition) call`, location });
820
+ return;
821
+ }
822
+ // Suppression/control calls that declare NO case
823
+ // (`test.skip(cond[, reason])` in a helper or hook,
824
+ // `test.setTimeout`-adjacent modifiers without a title+callback):
825
+ // suite/runner control, never a test declaration — a non-title
826
+ // first argument (`true`, a timeout) or a missing callback must not
827
+ // become a dynamic-title gap (consumer migration, E22:
828
+ // `test.skip(true, \`...${var}...\`)` in helpers produced phantom
829
+ // dynamic-title rows). The declaration forms `test.skip(title, fn)`
830
+ // / `test.only(title, fn)` / `test.fixme(title, fn)` — static OR
831
+ // parameterized title WITH a callback — fall through to entry
832
+ // creation with their skip/only/fixme signal, so a zero-instance
833
+ // skipped template stays a visible blocking row (fail-closed: adding
834
+ // `.skip` must never complete the inventory by dropping the case).
835
+ if (suppression.length > 0 &&
836
+ !isExtend &&
837
+ !lifecycle &&
838
+ !isDescribe &&
839
+ !eachCall) {
840
+ const controlTitle = titleArgument !== undefined ? titleOf(titleArgument) : null;
841
+ if (controlTitle === null || callback === undefined) {
842
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
843
+ return;
844
+ }
845
+ }
846
+ // Known-non-test callee: a locally-defined plain function (no
847
+ // test-structure reference in its body), resolved in-file or through
848
+ // one import/export hop — an ordinary helper call (step wrappers,
849
+ // UI callbacks factored into named functions), never a test
850
+ // registration. No row; nested calls still walked.
851
+ if (resolution === 'not-a-test' && !isExtend && !isDescribe) {
852
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
853
+ return;
854
+ }
855
+ // Bare identifier with no module-scope evidence at all (no import,
856
+ // no declaration — e.g. a function-scope UI callback or step-label
857
+ // helper): not a test alias. Test-structure names (test/it/describe)
858
+ // stay visible — an import-less global registration is still a
859
+ // possible test. A failed relative import already carries its own
860
+ // unresolved-import row AND an import-broken marker, so attempted
861
+ // aliases never take this path.
862
+ if (resolution === 'unknown' &&
863
+ names.length === 0 &&
864
+ !isExtend &&
865
+ !isDescribe &&
866
+ !TEST_STRUCTURE_NAMES.has(chain.base) &&
867
+ !model.bindings.has(chain.base)) {
868
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
869
+ return;
870
+ }
871
+ if (isDescribe || (eachCall && names.includes('describe'))) {
872
+ // describe(...) — push its title/signal scope over the callback body.
873
+ const title = titleOf(titleArgument);
874
+ const childStack = [...describeStack];
875
+ if (title !== null) {
876
+ childStack.push({
877
+ titles: [title.title],
878
+ signals: suppression.map((kind) => ({
879
+ kind: kind,
880
+ detail: `test.describe.${kind} modifier`,
881
+ location,
882
+ })),
883
+ });
884
+ }
885
+ if (callback !== undefined) {
886
+ ts.forEachChild(callback, (child) => visit(child, childStack, enclosing));
887
+ }
888
+ return;
889
+ }
890
+ if (effectiveResolution === 'test' && !isExtend && !lifecycle && !isDescribe && titleArgument !== undefined && !eachCall) {
891
+ const title = titleOf(titleArgument);
892
+ if (title === null) {
893
+ state.result.unresolved.push({
894
+ code: 'dynamic-title',
895
+ detail: 'test title is computed and cannot be resolved statically',
896
+ file,
897
+ titlePath: [UNRESOLVED_TITLE_PLACEHOLDER],
898
+ location,
899
+ });
900
+ return;
901
+ }
902
+ const entry = buildEntry({
903
+ file,
904
+ source,
905
+ node,
906
+ callback,
907
+ titlePath: [...describeStack.flatMap((scope) => scope.titles), title.title],
908
+ parameterized: title.parameterized,
909
+ inheritedSignals: describeStack.flatMap((scope) => scope.signals),
910
+ suppression,
911
+ location,
912
+ fileHttpClient,
913
+ fileMock,
914
+ });
915
+ state.result.entries.push(entry);
916
+ // Walk the body so inner zero-arg `test.skip()` / `test.fixme()`
917
+ // calls attach to THIS entry's signals.
918
+ if (callback !== undefined) {
919
+ const enclosing = { facts: entry.facts, signals: entry.signals };
920
+ ts.forEachChild(callback, (child) => visit(child, describeStack, enclosing));
921
+ }
922
+ return;
923
+ }
924
+ if (effectiveResolution === 'test' && eachCall && !isExtend && !lifecycle) {
925
+ // test.each([...])(title, fn): the OUTER call carries the title.
926
+ const outer = node.parent;
927
+ if (ts.isCallExpression(outer) && outer.expression === node) {
928
+ const title = titleOf(outer.arguments[0]);
929
+ const outerCallback = outer.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
930
+ const outerLocation = locationOf(file, source, outer);
931
+ if (title === null) {
932
+ state.result.unresolved.push({
933
+ code: 'dynamic-title',
934
+ detail: 'parameterized test title is computed and cannot be resolved statically',
935
+ file,
936
+ titlePath: [UNRESOLVED_TITLE_PLACEHOLDER],
937
+ location: outerLocation,
938
+ });
939
+ return;
940
+ }
941
+ const entry = buildEntry({
942
+ file,
943
+ source,
944
+ node: outer,
945
+ callback: outerCallback,
946
+ titlePath: [...describeStack.flatMap((scope) => scope.titles), title.title],
947
+ parameterized: 'each',
948
+ inheritedSignals: describeStack.flatMap((scope) => scope.signals),
949
+ suppression,
950
+ location: outerLocation,
951
+ fileHttpClient,
952
+ fileMock,
953
+ });
954
+ state.result.entries.push(entry);
955
+ if (outerCallback !== undefined) {
956
+ const enclosing = { facts: entry.facts, signals: entry.signals };
957
+ ts.forEachChild(outerCallback, (child) => visit(child, describeStack, enclosing));
958
+ }
959
+ return;
960
+ }
961
+ return; // the inner test.each(data) call itself: handled via the outer
962
+ }
963
+ if ((resolution === 'wrapper-unresolvable' || resolution === 'unknown' || resolution === 'budget') &&
964
+ !isExtend &&
965
+ !lifecycle &&
966
+ !isDescribe &&
967
+ titleArgument !== undefined &&
968
+ typeof titleOf(titleArgument)?.title === 'string' &&
969
+ node.arguments.some((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument))) {
970
+ // A call that LOOKS like a test (literal title + callback) through
971
+ // a name we cannot prove: recorded, never omitted, with its call
972
+ // location. Single-argument calls (`page.goto('/x')`) are NOT
973
+ // test-shaped and stay unclassified.
974
+ const code = resolution === 'wrapper-unresolvable'
975
+ ? 'unresolved-wrapper'
976
+ : resolution === 'budget'
977
+ ? 'traversal-budget-exceeded'
978
+ : 'unresolved-test-alias';
979
+ const detail = resolution === 'wrapper-unresolvable'
980
+ ? `call target '${chain.base}' is a declared wrapper whose test binding cannot be proven statically`
981
+ : resolution === 'budget'
982
+ ? `import/alias traversal budget exceeded before '${chain.base}' could be resolved`
983
+ : `call target '${chain.base}' is not a known test binding (outside the scanned set or not statically provable)`;
984
+ state.result.unresolved.push({
985
+ code,
986
+ detail,
987
+ file,
988
+ titlePath: [...describeStack.flatMap((scope) => scope.titles), titleOf(titleArgument)?.title ?? UNRESOLVED_TITLE_PLACEHOLDER],
989
+ location,
990
+ });
991
+ return;
992
+ }
993
+ // Everything else: keep walking (non-test calls, bare identifiers).
994
+ ts.forEachChild(node, (child) => visit(child, describeStack, enclosing));
995
+ };
996
+ visit(source, [], null);
997
+ }
998
+ /** Builds one static entry: facts from the callback body + signals. */
999
+ function buildEntry(input) {
1000
+ const { file, source, node, callback, titlePath, location } = input;
1001
+ const params = callback === undefined ? [] : signatureParamsOf(callback);
1002
+ const signals = [...input.inheritedSignals];
1003
+ for (const kind of input.suppression) {
1004
+ signals.push({ kind: kind, detail: `test.${kind} modifier`, location });
1005
+ }
1006
+ let pageRoute = null;
1007
+ let httpClientCall = null;
1008
+ if (callback !== undefined) {
1009
+ pageRoute = findPageRoute(callback, source, file);
1010
+ httpClientCall = findHttpClientCall(callback, source, file);
1011
+ }
1012
+ return {
1013
+ file,
1014
+ titlePath,
1015
+ title: titlePath[titlePath.length - 1] ?? UNRESOLVED_TITLE_PLACEHOLDER,
1016
+ location,
1017
+ parameterIdentity: input.parameterized === false ? null : input.parameterized === 'each' ? 'each' : 'template',
1018
+ signals,
1019
+ facts: {
1020
+ signatureParams: params,
1021
+ pageRoute,
1022
+ httpClientCall,
1023
+ fileHttpClientCall: input.fileHttpClient,
1024
+ fileMockImport: input.fileMock,
1025
+ },
1026
+ };
1027
+ }
1028
+ //# sourceMappingURL=static-discovery.js.map