@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.
- package/LICENSE +202 -0
- package/bin/gateforge-witness.js +40 -0
- package/dist/attestation/proxy.d.ts +40 -0
- package/dist/attestation/proxy.d.ts.map +1 -0
- package/dist/attestation/proxy.js +78 -0
- package/dist/attestation/proxy.js.map +1 -0
- package/dist/constants.d.ts +130 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +138 -0
- package/dist/constants.js.map +1 -0
- package/dist/discovery/adapters.d.ts +167 -0
- package/dist/discovery/adapters.d.ts.map +1 -0
- package/dist/discovery/adapters.js +276 -0
- package/dist/discovery/adapters.js.map +1 -0
- package/dist/discovery/discover.d.ts +57 -0
- package/dist/discovery/discover.d.ts.map +1 -0
- package/dist/discovery/discover.js +544 -0
- package/dist/discovery/discover.js.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +15 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/inference.d.ts +63 -0
- package/dist/discovery/inference.d.ts.map +1 -0
- package/dist/discovery/inference.js +173 -0
- package/dist/discovery/inference.js.map +1 -0
- package/dist/discovery/pytest-adapter.d.ts +205 -0
- package/dist/discovery/pytest-adapter.d.ts.map +1 -0
- package/dist/discovery/pytest-adapter.js +548 -0
- package/dist/discovery/pytest-adapter.js.map +1 -0
- package/dist/discovery/reconcile.d.ts +106 -0
- package/dist/discovery/reconcile.d.ts.map +1 -0
- package/dist/discovery/reconcile.js +268 -0
- package/dist/discovery/reconcile.js.map +1 -0
- package/dist/discovery/runner-env.d.ts +80 -0
- package/dist/discovery/runner-env.d.ts.map +1 -0
- package/dist/discovery/runner-env.js +139 -0
- package/dist/discovery/runner-env.js.map +1 -0
- package/dist/discovery/static-discovery.d.ts +121 -0
- package/dist/discovery/static-discovery.d.ts.map +1 -0
- package/dist/discovery/static-discovery.js +1028 -0
- package/dist/discovery/static-discovery.js.map +1 -0
- package/dist/discovery/supervised-run.d.ts +110 -0
- package/dist/discovery/supervised-run.d.ts.map +1 -0
- package/dist/discovery/supervised-run.js +328 -0
- package/dist/discovery/supervised-run.js.map +1 -0
- package/dist/discovery/trusted-config.d.ts +103 -0
- package/dist/discovery/trusted-config.d.ts.map +1 -0
- package/dist/discovery/trusted-config.js +117 -0
- package/dist/discovery/trusted-config.js.map +1 -0
- package/dist/fixture/evidence.d.ts +191 -0
- package/dist/fixture/evidence.d.ts.map +1 -0
- package/dist/fixture/evidence.js +366 -0
- package/dist/fixture/evidence.js.map +1 -0
- package/dist/fixture/fixture.d.ts +37 -0
- package/dist/fixture/fixture.d.ts.map +1 -0
- package/dist/fixture/fixture.js +38 -0
- package/dist/fixture/fixture.js.map +1 -0
- package/dist/fixture/witness-client.d.ts +164 -0
- package/dist/fixture/witness-client.d.ts.map +1 -0
- package/dist/fixture/witness-client.js +253 -0
- package/dist/fixture/witness-client.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +16 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +21 -0
- package/dist/json.js.map +1 -0
- package/dist/reporter/ledger.d.ts +105 -0
- package/dist/reporter/ledger.d.ts.map +1 -0
- package/dist/reporter/ledger.js +197 -0
- package/dist/reporter/ledger.js.map +1 -0
- package/dist/reporter/reporter.cjs +105 -0
- package/dist/reporter/reporter.cjs.map +1 -0
- package/dist/reporter/reporter.d.cts +35 -0
- package/dist/reporter/reporter.d.cts.map +1 -0
- package/dist/reporter/reporter.d.ts +167 -0
- package/dist/reporter/reporter.d.ts.map +1 -0
- package/dist/reporter/reporter.js +673 -0
- package/dist/reporter/reporter.js.map +1 -0
- package/dist/setup.d.ts +49 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +167 -0
- package/dist/setup.js.map +1 -0
- package/dist/supervisor/client.d.ts +73 -0
- package/dist/supervisor/client.d.ts.map +1 -0
- package/dist/supervisor/client.js +164 -0
- package/dist/supervisor/client.js.map +1 -0
- package/dist/supervisor/drain.d.ts +45 -0
- package/dist/supervisor/drain.d.ts.map +1 -0
- package/dist/supervisor/drain.js +235 -0
- package/dist/supervisor/drain.js.map +1 -0
- package/dist/supervisor/index.d.ts +16 -0
- package/dist/supervisor/index.d.ts.map +1 -0
- package/dist/supervisor/index.js +13 -0
- package/dist/supervisor/index.js.map +1 -0
- package/dist/supervisor/spool.d.ts +137 -0
- package/dist/supervisor/spool.d.ts.map +1 -0
- package/dist/supervisor/spool.js +263 -0
- package/dist/supervisor/spool.js.map +1 -0
- package/dist/surface.d.ts +134 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +139 -0
- package/dist/surface.js.map +1 -0
- package/dist/witness/adapter-registry.d.ts +38 -0
- package/dist/witness/adapter-registry.d.ts.map +1 -0
- package/dist/witness/adapter-registry.js +158 -0
- package/dist/witness/adapter-registry.js.map +1 -0
- package/dist/witness/bin.d.ts +21 -0
- package/dist/witness/bin.d.ts.map +1 -0
- package/dist/witness/bin.js +172 -0
- package/dist/witness/bin.js.map +1 -0
- package/dist/witness/browser.d.ts +173 -0
- package/dist/witness/browser.d.ts.map +1 -0
- package/dist/witness/browser.js +580 -0
- package/dist/witness/browser.js.map +1 -0
- package/dist/witness/classifications.d.ts +42 -0
- package/dist/witness/classifications.d.ts.map +1 -0
- package/dist/witness/classifications.js +87 -0
- package/dist/witness/classifications.js.map +1 -0
- package/dist/witness/env-attestation.d.ts +98 -0
- package/dist/witness/env-attestation.d.ts.map +1 -0
- package/dist/witness/env-attestation.js +202 -0
- package/dist/witness/env-attestation.js.map +1 -0
- package/dist/witness/loopback-pins.d.ts +79 -0
- package/dist/witness/loopback-pins.d.ts.map +1 -0
- package/dist/witness/loopback-pins.js +244 -0
- package/dist/witness/loopback-pins.js.map +1 -0
- package/dist/witness/server.d.ts +34 -0
- package/dist/witness/server.d.ts.map +1 -0
- package/dist/witness/server.js +2539 -0
- package/dist/witness/server.js.map +1 -0
- package/dist/witness/types.d.ts +643 -0
- package/dist/witness/types.d.ts.map +1 -0
- package/dist/witness/types.js +2 -0
- package/dist/witness/types.js.map +1 -0
- package/package.json +64 -0
- 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
|