@gate-forge/pack-playwright 0.8.0 → 0.9.1
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/dist/discovery/discover.d.ts +22 -0
- package/dist/discovery/discover.d.ts.map +1 -1
- package/dist/discovery/discover.js +220 -28
- package/dist/discovery/discover.js.map +1 -1
- package/dist/discovery/index.d.ts +1 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +1 -0
- package/dist/discovery/index.js.map +1 -1
- package/dist/discovery/reconcile.d.ts +38 -0
- package/dist/discovery/reconcile.d.ts.map +1 -1
- package/dist/discovery/reconcile.js +77 -1
- package/dist/discovery/reconcile.js.map +1 -1
- package/dist/discovery/runner-file-scope.d.ts +118 -0
- package/dist/discovery/runner-file-scope.d.ts.map +1 -0
- package/dist/discovery/runner-file-scope.js +753 -0
- package/dist/discovery/runner-file-scope.js.map +1 -0
- package/dist/discovery/static-discovery.d.ts +11 -0
- package/dist/discovery/static-discovery.d.ts.map +1 -1
- package/dist/discovery/static-discovery.js +23 -3
- package/dist/discovery/static-discovery.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/reporter/project-graph-reporter.d.ts +45 -16
- package/dist/reporter/project-graph-reporter.d.ts.map +1 -1
- package/dist/reporter/project-graph-reporter.js +56 -16
- package/dist/reporter/project-graph-reporter.js.map +1 -1
- package/package.json +7 -4
- package/python/__pycache__/gateforge_persistence_intents.cpython-312.pyc +0 -0
- package/python/__pycache__/gateforge_persistence_intents.cpython-314.pyc +0 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner file scopes: WHICH files a runner's own configuration collects.
|
|
3
|
+
*
|
|
4
|
+
* The static scan is seeded from Gateforge's own include globs, so it
|
|
5
|
+
* can find test-shaped calls anywhere in the repository — including
|
|
6
|
+
* files the configured runner would never run (a vitest suite in a
|
|
7
|
+
* playwright-configured repository, a spec under a `testDir` the config
|
|
8
|
+
* excludes). Cataloguing those as that runner's tests is wrong twice
|
|
9
|
+
* over: the runner will not execute them, and their assertions were
|
|
10
|
+
* never proved.
|
|
11
|
+
*
|
|
12
|
+
* This module answers the file question from DATA, never from a guess:
|
|
13
|
+
* - Playwright: the enumeration's own project-graph reporter hands us
|
|
14
|
+
* the resolved per-project `testDir`/`testMatch`/`testIgnore` (see
|
|
15
|
+
* `reporter/project-graph-reporter.ts`) — the runner's own words.
|
|
16
|
+
* - Vitest: a bounded, AST-only read of the repository's own
|
|
17
|
+
* `vitest.config.*`/`vite.config.*` literal `test.include` /
|
|
18
|
+
* `test.exclude` / `test.globals` values. A spread of
|
|
19
|
+
* vitest's own imported defaults (`...configDefaults.exclude`,
|
|
20
|
+
* the idiom the vitest docs recommend) reads as those
|
|
21
|
+
* defaults — but only when the name is imported from
|
|
22
|
+
* `vitest/config` or `vitest` in the same file. No consumer
|
|
23
|
+
* code runs and no expression is evaluated.
|
|
24
|
+
*
|
|
25
|
+
* Every scope is FAIL-OPEN: when a selection cannot be read faithfully
|
|
26
|
+
* (a computed glob, a function-valued selector, a missing config), the
|
|
27
|
+
* scope is not authoritative and selects every file, which is exactly
|
|
28
|
+
* the pre-existing behaviour. Narrowing a catalog on a mis-read glob is
|
|
29
|
+
* the one failure mode this module must never have.
|
|
30
|
+
*/
|
|
31
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
33
|
+
import ts from 'typescript';
|
|
34
|
+
/** Vitest's documented default `test.include`. */
|
|
35
|
+
const VITEST_DEFAULT_INCLUDE = ['**/*.{test,spec}.?(c|m)[jt]s?(x)'];
|
|
36
|
+
/** Vitest's documented default `test.exclude`. */
|
|
37
|
+
const VITEST_DEFAULT_EXCLUDE = [
|
|
38
|
+
'**/node_modules/**',
|
|
39
|
+
'**/dist/**',
|
|
40
|
+
'**/cypress/**',
|
|
41
|
+
'**/.{idea,git,cache,output,temp}/**',
|
|
42
|
+
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
|
|
43
|
+
];
|
|
44
|
+
/** Config file names a vitest repository may declare, in resolution order. */
|
|
45
|
+
const VITEST_CONFIG_NAMES = [
|
|
46
|
+
'vitest.config.ts',
|
|
47
|
+
'vitest.config.mts',
|
|
48
|
+
'vitest.config.cts',
|
|
49
|
+
'vitest.config.js',
|
|
50
|
+
'vitest.config.mjs',
|
|
51
|
+
'vitest.config.cjs',
|
|
52
|
+
'vite.config.ts',
|
|
53
|
+
'vite.config.mts',
|
|
54
|
+
'vite.config.cts',
|
|
55
|
+
'vite.config.js',
|
|
56
|
+
'vite.config.mjs',
|
|
57
|
+
'vite.config.cjs',
|
|
58
|
+
];
|
|
59
|
+
/** Escapes one character for use inside a regular expression. */
|
|
60
|
+
function escapeRegExp(character) {
|
|
61
|
+
return character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reads a parenthesized group starting at `openIndex` (which must hold
|
|
65
|
+
* `(`), honouring nesting and backslash escapes.
|
|
66
|
+
*
|
|
67
|
+
* Args:
|
|
68
|
+
* pattern: the glob being translated.
|
|
69
|
+
* openIndex: index of the group's opening parenthesis.
|
|
70
|
+
*
|
|
71
|
+
* Returns:
|
|
72
|
+
* {body, end}: the group's body and the index just past its `)`; null
|
|
73
|
+
* when the group is unbalanced.
|
|
74
|
+
*/
|
|
75
|
+
function readGroup(pattern, openIndex) {
|
|
76
|
+
let depth = 0;
|
|
77
|
+
for (let index = openIndex; index < pattern.length; index += 1) {
|
|
78
|
+
const character = pattern[index];
|
|
79
|
+
if (character === '\\') {
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (character === '(')
|
|
84
|
+
depth += 1;
|
|
85
|
+
else if (character === ')') {
|
|
86
|
+
depth -= 1;
|
|
87
|
+
if (depth === 0) {
|
|
88
|
+
return { body: pattern.slice(openIndex + 1, index), end: index + 1 };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Finds the `}` closing the `{` at `openIndex`, honouring nesting.
|
|
96
|
+
*
|
|
97
|
+
* Args:
|
|
98
|
+
* pattern: the glob being translated.
|
|
99
|
+
* openIndex: index of the brace group's opening `{`.
|
|
100
|
+
*
|
|
101
|
+
* Returns:
|
|
102
|
+
* number: the index of the matching `}`, or -1 when unbalanced.
|
|
103
|
+
*/
|
|
104
|
+
function findClosingBrace(pattern, openIndex) {
|
|
105
|
+
let depth = 0;
|
|
106
|
+
for (let index = openIndex; index < pattern.length; index += 1) {
|
|
107
|
+
const character = pattern[index];
|
|
108
|
+
if (character === '\\') {
|
|
109
|
+
index += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (character === '{')
|
|
113
|
+
depth += 1;
|
|
114
|
+
else if (character === '}') {
|
|
115
|
+
depth -= 1;
|
|
116
|
+
if (depth === 0)
|
|
117
|
+
return index;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return -1;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Splits a brace group body on its top-level commas.
|
|
124
|
+
*
|
|
125
|
+
* Args:
|
|
126
|
+
* body: the text between the braces.
|
|
127
|
+
*
|
|
128
|
+
* Returns:
|
|
129
|
+
* string[]: the alternatives; null when a nested group is unbalanced.
|
|
130
|
+
*/
|
|
131
|
+
function splitAlternatives(body) {
|
|
132
|
+
const parts = [];
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let current = '';
|
|
135
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
136
|
+
const character = body[index] ?? '';
|
|
137
|
+
if (character === '\\') {
|
|
138
|
+
current += character + (body[index + 1] ?? '');
|
|
139
|
+
index += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (character === '(' || character === '[')
|
|
143
|
+
depth += 1;
|
|
144
|
+
else if (character === ')' || character === ']')
|
|
145
|
+
depth -= 1;
|
|
146
|
+
else if (character === ',' && depth === 0) {
|
|
147
|
+
parts.push(current);
|
|
148
|
+
current = '';
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (depth < 0)
|
|
152
|
+
return null;
|
|
153
|
+
current += character;
|
|
154
|
+
}
|
|
155
|
+
if (depth !== 0)
|
|
156
|
+
return null;
|
|
157
|
+
parts.push(current);
|
|
158
|
+
return parts;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Translates one glob into a regular-expression source.
|
|
162
|
+
*
|
|
163
|
+
* Supported: `**`, `*`, `?`, character classes, brace alternation
|
|
164
|
+
* (`{a,b}`), and the extglob forms `@(a|b)`, `?(a|b)`, `+(a|b)` — which
|
|
165
|
+
* together cover the runners' own documented selection globs, such as
|
|
166
|
+
* playwright's default test glob (a globstar, `*.@(spec|test)` and an
|
|
167
|
+
* optional `.(c|m)[jt]s?(x)` extension). Anything else (a negated
|
|
168
|
+
* class or extglob, an unbalanced group) yields null, and null means
|
|
169
|
+
* "this pattern cannot narrow" — never "this pattern matches
|
|
170
|
+
* nothing".
|
|
171
|
+
*
|
|
172
|
+
* Args:
|
|
173
|
+
* pattern: the glob to translate.
|
|
174
|
+
*
|
|
175
|
+
* Returns:
|
|
176
|
+
* string: the regexp source, or null when it cannot be translated.
|
|
177
|
+
*/
|
|
178
|
+
function translateGlob(pattern) {
|
|
179
|
+
let source = '';
|
|
180
|
+
let index = 0;
|
|
181
|
+
while (index < pattern.length) {
|
|
182
|
+
const character = pattern[index] ?? '';
|
|
183
|
+
if (character === '\\') {
|
|
184
|
+
const next = pattern[index + 1];
|
|
185
|
+
if (next === undefined)
|
|
186
|
+
return null;
|
|
187
|
+
source += escapeRegExp(next);
|
|
188
|
+
index += 2;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (character === '*') {
|
|
192
|
+
let stars = 0;
|
|
193
|
+
while (pattern[index] === '*') {
|
|
194
|
+
stars += 1;
|
|
195
|
+
index += 1;
|
|
196
|
+
}
|
|
197
|
+
if (stars >= 2) {
|
|
198
|
+
if (pattern[index] === '/') {
|
|
199
|
+
source += '(?:.*/)?';
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
source += '.*';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
source += '[^/]*';
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (character === '?') {
|
|
212
|
+
if (pattern[index + 1] === '(') {
|
|
213
|
+
const group = readGroup(pattern, index + 1);
|
|
214
|
+
if (group === null)
|
|
215
|
+
return null;
|
|
216
|
+
const inner = translateGlob(group.body);
|
|
217
|
+
if (inner === null)
|
|
218
|
+
return null;
|
|
219
|
+
source += `(?:${inner})?`;
|
|
220
|
+
index = group.end;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
source += '[^/]';
|
|
224
|
+
index += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if ((character === '@' || character === '+') && pattern[index + 1] === '(') {
|
|
228
|
+
const group = readGroup(pattern, index + 1);
|
|
229
|
+
if (group === null)
|
|
230
|
+
return null;
|
|
231
|
+
const inner = translateGlob(group.body);
|
|
232
|
+
if (inner === null)
|
|
233
|
+
return null;
|
|
234
|
+
source += character === '@' ? `(?:${inner})` : `(?:${inner})+`;
|
|
235
|
+
index = group.end;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (character === '[') {
|
|
239
|
+
const end = pattern.indexOf(']', index + 1);
|
|
240
|
+
if (end === -1)
|
|
241
|
+
return null;
|
|
242
|
+
source += pattern.slice(index, end + 1);
|
|
243
|
+
index = end + 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (character === '{') {
|
|
247
|
+
const end = findClosingBrace(pattern, index);
|
|
248
|
+
if (end === -1)
|
|
249
|
+
return null;
|
|
250
|
+
const alternatives = splitAlternatives(pattern.slice(index + 1, end));
|
|
251
|
+
if (alternatives === null)
|
|
252
|
+
return null;
|
|
253
|
+
const parts = [];
|
|
254
|
+
for (const alternative of alternatives) {
|
|
255
|
+
const fragment = translateGlob(alternative);
|
|
256
|
+
if (fragment === null)
|
|
257
|
+
return null;
|
|
258
|
+
parts.push(fragment);
|
|
259
|
+
}
|
|
260
|
+
source += `(?:${parts.join('|')})`;
|
|
261
|
+
index = end + 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// A `!` introduces a negated class or a `!(…)` extglob; neither has a
|
|
265
|
+
// sound translation here, and a wrong one would drop real tests.
|
|
266
|
+
if (character === '!')
|
|
267
|
+
return null;
|
|
268
|
+
source += escapeRegExp(character);
|
|
269
|
+
index += 1;
|
|
270
|
+
}
|
|
271
|
+
return source;
|
|
272
|
+
}
|
|
273
|
+
/** Compiled selection globs, memoized per pattern. */
|
|
274
|
+
const compiledGlobs = new Map();
|
|
275
|
+
/**
|
|
276
|
+
* Compiles one selection glob, memoized per pattern.
|
|
277
|
+
*
|
|
278
|
+
* Args:
|
|
279
|
+
glob: the selection glob.
|
|
280
|
+
*
|
|
281
|
+
* Returns:
|
|
282
|
+
RegExp | null: the compiled matcher, or null when the pattern cannot
|
|
283
|
+
be translated faithfully (never "matches nothing").
|
|
284
|
+
*/
|
|
285
|
+
function compileGlobPattern(glob) {
|
|
286
|
+
if (compiledGlobs.has(glob))
|
|
287
|
+
return compiledGlobs.get(glob) ?? null;
|
|
288
|
+
const source = translateGlob(glob);
|
|
289
|
+
let compiled = null;
|
|
290
|
+
if (source !== null) {
|
|
291
|
+
try {
|
|
292
|
+
compiled = new RegExp(`^${source}$`);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
compiled = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
compiledGlobs.set(glob, compiled);
|
|
299
|
+
return compiled;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether one glob selects a repo-relative path.
|
|
303
|
+
*
|
|
304
|
+
* Args:
|
|
305
|
+
* glob: the selection glob.
|
|
306
|
+
* file: repo-relative posix path, relative to the scope root.
|
|
307
|
+
*
|
|
308
|
+
* Returns:
|
|
309
|
+
* boolean: false only when the pattern is translatable and does NOT
|
|
310
|
+
* match. An untranslatable pattern returns true so a mis-read glob can
|
|
311
|
+
* never narrow the catalog.
|
|
312
|
+
*/
|
|
313
|
+
function globSelectsFile(glob, file) {
|
|
314
|
+
const compiled = compileGlobPattern(glob);
|
|
315
|
+
if (compiled === null)
|
|
316
|
+
return true;
|
|
317
|
+
return compiled.test(file);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Whether a runner's own configuration collects this file.
|
|
321
|
+
*
|
|
322
|
+
* Args:
|
|
323
|
+
* scope: the runner's selection.
|
|
324
|
+
* cwd: absolute repo root.
|
|
325
|
+
* repoRelativeFile: repo-relative posix path.
|
|
326
|
+
*
|
|
327
|
+
* Returns:
|
|
328
|
+
* boolean: true when the file is one of the runner's test files. A
|
|
329
|
+
* non-authoritative scope always returns true (fail open).
|
|
330
|
+
*/
|
|
331
|
+
export function scopeSelectsFile(scope, cwd, repoRelativeFile) {
|
|
332
|
+
if (!scope.authoritative)
|
|
333
|
+
return true;
|
|
334
|
+
const absolute = isAbsolute(repoRelativeFile) ? repoRelativeFile : resolve(cwd, repoRelativeFile);
|
|
335
|
+
const file = relative(scope.root, absolute).split('\\').join('/');
|
|
336
|
+
if (file === '' || file.startsWith('../'))
|
|
337
|
+
return false;
|
|
338
|
+
if (scope.include.length > 0 && !scope.include.some((glob) => globSelectsFile(glob, file))) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
return !scope.exclude.some((glob) => globSelectsFile(glob, file));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Whether ANY of the runner's scopes collects the file — the union over
|
|
345
|
+
* projects, which is how a runner collects a file at all.
|
|
346
|
+
*/
|
|
347
|
+
export function anyScopeSelectsFile(scopes, cwd, repoRelativeFile) {
|
|
348
|
+
if (scopes.length === 0)
|
|
349
|
+
return true;
|
|
350
|
+
return scopes.some((scope) => scopeSelectsFile(scope, cwd, repoRelativeFile));
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The playwright scopes the enumeration's runner resolved.
|
|
354
|
+
*
|
|
355
|
+
* Args:
|
|
356
|
+
* projects: the per-project selection the project-graph reporter read.
|
|
357
|
+
*
|
|
358
|
+
* Returns:
|
|
359
|
+
* RunnerFileScope[]: one scope per project whose selection was data.
|
|
360
|
+
* Empty when the runner resolved none, which selects nothing away.
|
|
361
|
+
*/
|
|
362
|
+
export function playwrightFileScopes(projects) {
|
|
363
|
+
const scopes = [];
|
|
364
|
+
for (const project of projects) {
|
|
365
|
+
const include = project.testMatch.map((glob) => glob.replace(/^\.\//, ''));
|
|
366
|
+
const exclude = project.testIgnore.map((glob) => glob.replace(/^\.\//, ''));
|
|
367
|
+
// A selection this boundary cannot read as DATA — a root that is not
|
|
368
|
+
// a location, a pattern with no sound translation — makes the whole
|
|
369
|
+
// scope fail open (every file) rather than narrow on a guess, and
|
|
370
|
+
// says so in `note` so a wide selection is never mistaken for a
|
|
371
|
+
// narrow one.
|
|
372
|
+
const unreadable = [
|
|
373
|
+
...(isAbsolute(project.testDir) ? [] : [`testDir '${project.testDir}' is not an absolute path`]),
|
|
374
|
+
...[...include, ...exclude]
|
|
375
|
+
.filter((glob) => compileGlobPattern(glob) === null)
|
|
376
|
+
.map((glob) => `selection pattern '${glob}' is not a translatable glob`),
|
|
377
|
+
];
|
|
378
|
+
scopes.push({
|
|
379
|
+
runner: 'playwright',
|
|
380
|
+
root: project.testDir,
|
|
381
|
+
include,
|
|
382
|
+
exclude,
|
|
383
|
+
globals: false,
|
|
384
|
+
authoritative: unreadable.length === 0,
|
|
385
|
+
...(unreadable.length > 0 ? { note: `${unreadable.join('; ')} — this project collects every file` } : {}),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return scopes;
|
|
389
|
+
}
|
|
390
|
+
/** Reads a string-literal node, else undefined. */
|
|
391
|
+
function literalString(node) {
|
|
392
|
+
if (node === undefined)
|
|
393
|
+
return undefined;
|
|
394
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
395
|
+
return node.text;
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
/** Reads a boolean-literal node, else undefined. */
|
|
399
|
+
function literalBoolean(node) {
|
|
400
|
+
if (node === undefined)
|
|
401
|
+
return undefined;
|
|
402
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
403
|
+
return true;
|
|
404
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword)
|
|
405
|
+
return false;
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Which vitest default selections the file imports by name,
|
|
410
|
+
* read from its import declarations only.
|
|
411
|
+
*
|
|
412
|
+
* Args:
|
|
413
|
+
* parsed: the parsed config source file.
|
|
414
|
+
*
|
|
415
|
+
* Returns:
|
|
416
|
+
* VitestDefaultImports: the local names bound to vitest's
|
|
417
|
+
* own defaults; empty sets when the file imports none.
|
|
418
|
+
*/
|
|
419
|
+
function vitestDefaultImportsOf(parsed) {
|
|
420
|
+
const imported = {
|
|
421
|
+
configDefaults: new Set(),
|
|
422
|
+
defaultInclude: new Set(),
|
|
423
|
+
defaultExclude: new Set(),
|
|
424
|
+
};
|
|
425
|
+
for (const statement of parsed.statements) {
|
|
426
|
+
if (!ts.isImportDeclaration(statement))
|
|
427
|
+
continue;
|
|
428
|
+
const specifier = statement.moduleSpecifier;
|
|
429
|
+
if (!ts.isStringLiteral(specifier))
|
|
430
|
+
continue;
|
|
431
|
+
// Only vitest's own entry points hand out its defaults.
|
|
432
|
+
if (specifier.text !== 'vitest/config' && specifier.text !== 'vitest')
|
|
433
|
+
continue;
|
|
434
|
+
const named = statement.importClause?.namedBindings;
|
|
435
|
+
if (named === undefined || !ts.isNamedImports(named))
|
|
436
|
+
continue;
|
|
437
|
+
for (const binding of named.elements) {
|
|
438
|
+
const source = binding.propertyName?.text ?? binding.name.text;
|
|
439
|
+
if (source === 'configDefaults')
|
|
440
|
+
imported.configDefaults.add(binding.name.text);
|
|
441
|
+
else if (source === 'defaultInclude')
|
|
442
|
+
imported.defaultInclude.add(binding.name.text);
|
|
443
|
+
else if (source === 'defaultExclude')
|
|
444
|
+
imported.defaultExclude.add(binding.name.text);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return imported;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The vitest default selection one expression names: a bare
|
|
451
|
+
* `configDefaults.include`, an imported `defaultExclude`, or
|
|
452
|
+
* the inner expression of a spread of either. Any other
|
|
453
|
+
* identifier or property is NOT data this boundary may use.
|
|
454
|
+
*
|
|
455
|
+
* Args:
|
|
456
|
+
* node: the expression to read.
|
|
457
|
+
* imported: the vitest defaults the file imports.
|
|
458
|
+
*
|
|
459
|
+
* Returns:
|
|
460
|
+
* readonly string[] | undefined: the named default
|
|
461
|
+
* selection, or undefined when the expression names none.
|
|
462
|
+
*/
|
|
463
|
+
function vitestDefaultSelection(node, imported) {
|
|
464
|
+
// A default imported by its own name: `exclude: defaultExclude`.
|
|
465
|
+
if (ts.isIdentifier(node)) {
|
|
466
|
+
if (imported.defaultInclude.has(node.text))
|
|
467
|
+
return VITEST_DEFAULT_INCLUDE;
|
|
468
|
+
if (imported.defaultExclude.has(node.text))
|
|
469
|
+
return VITEST_DEFAULT_EXCLUDE;
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
// A member of the imported defaults object:
|
|
473
|
+
// `exclude: configDefaults.exclude`.
|
|
474
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
475
|
+
if (!imported.configDefaults.has(node.expression.text))
|
|
476
|
+
return undefined;
|
|
477
|
+
if (node.name.text === 'include')
|
|
478
|
+
return VITEST_DEFAULT_INCLUDE;
|
|
479
|
+
if (node.name.text === 'exclude')
|
|
480
|
+
return VITEST_DEFAULT_EXCLUDE;
|
|
481
|
+
}
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Reads a vitest `include`/`exclude` selection as data:
|
|
486
|
+
* string literals, a spread of vitest's own defaults
|
|
487
|
+
* (`[...configDefaults.exclude, …]` — the idiom the
|
|
488
|
+
* vitest docs recommend), or a bare default
|
|
489
|
+
* (`configDefaults.exclude`). Any other element makes the
|
|
490
|
+
* selection computed, which reads as undefined (fail-open).
|
|
491
|
+
*
|
|
492
|
+
* Args:
|
|
493
|
+
* node: the `include`/`exclude` initializer.
|
|
494
|
+
* imported: the vitest defaults the file imports.
|
|
495
|
+
*
|
|
496
|
+
* Returns:
|
|
497
|
+
* readonly string[] | undefined: the literal selection, or
|
|
498
|
+
* undefined when it is computed.
|
|
499
|
+
*/
|
|
500
|
+
function selectionStrings(node, imported) {
|
|
501
|
+
if (node === undefined)
|
|
502
|
+
return undefined;
|
|
503
|
+
const bare = vitestDefaultSelection(node, imported);
|
|
504
|
+
if (bare !== undefined)
|
|
505
|
+
return bare;
|
|
506
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
507
|
+
return [node.text];
|
|
508
|
+
if (!ts.isArrayLiteralExpression(node))
|
|
509
|
+
return undefined;
|
|
510
|
+
const values = [];
|
|
511
|
+
for (const element of node.elements) {
|
|
512
|
+
// `...configDefaults.exclude` widens the selection with
|
|
513
|
+
// vitest's own defaults instead of replacing them.
|
|
514
|
+
if (ts.isSpreadElement(element)) {
|
|
515
|
+
const spread = vitestDefaultSelection(element.expression, imported);
|
|
516
|
+
if (spread === undefined)
|
|
517
|
+
return undefined;
|
|
518
|
+
values.push(...spread);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const value = literalString(element);
|
|
522
|
+
if (value === undefined)
|
|
523
|
+
return undefined;
|
|
524
|
+
values.push(value);
|
|
525
|
+
}
|
|
526
|
+
return values;
|
|
527
|
+
}
|
|
528
|
+
/** The `test` property of an object literal, when it is one itself. */
|
|
529
|
+
function propertyOf(object, name) {
|
|
530
|
+
for (const member of object.properties) {
|
|
531
|
+
if (!ts.isPropertyAssignment(member))
|
|
532
|
+
continue;
|
|
533
|
+
const key = member.name;
|
|
534
|
+
const keyName = ts.isIdentifier(key) || ts.isStringLiteral(key) ? key.text : undefined;
|
|
535
|
+
if (keyName === name)
|
|
536
|
+
return member.initializer;
|
|
537
|
+
}
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
/** Directories never descended into when looking for runner configs. */
|
|
541
|
+
const CONFIG_SEARCH_PRUNED_DIRS = new Set([
|
|
542
|
+
'.git',
|
|
543
|
+
'node_modules',
|
|
544
|
+
'dist',
|
|
545
|
+
'build',
|
|
546
|
+
'out',
|
|
547
|
+
'coverage',
|
|
548
|
+
'test-results',
|
|
549
|
+
'playwright-report',
|
|
550
|
+
]);
|
|
551
|
+
/** How deep below the repo root a runner config is still read from. */
|
|
552
|
+
const MAX_CONFIG_SEARCH_DEPTH = 4;
|
|
553
|
+
/**
|
|
554
|
+
* Every vitest configuration the repository declares, at any depth a
|
|
555
|
+
* monorepo keeps one (a repo whose frontend owns `frontend/vitest.config.js`
|
|
556
|
+
* selects files under `frontend/`, not under the repo root).
|
|
557
|
+
*
|
|
558
|
+
* Args:
|
|
559
|
+
* cwd: absolute repo root.
|
|
560
|
+
*
|
|
561
|
+
* Returns:
|
|
562
|
+
* string[]: absolute config paths, sorted; empty when the repository
|
|
563
|
+
* declares none (the scope is then unknown, never empty).
|
|
564
|
+
*/
|
|
565
|
+
function findVitestConfigFiles(cwd) {
|
|
566
|
+
const found = [];
|
|
567
|
+
const walk = (dir, depth) => {
|
|
568
|
+
if (depth > MAX_CONFIG_SEARCH_DEPTH)
|
|
569
|
+
return;
|
|
570
|
+
let entries;
|
|
571
|
+
try {
|
|
572
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return; // unreadable dir: invisible, never a failure of other configs
|
|
576
|
+
}
|
|
577
|
+
for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
578
|
+
// Symlinked directories are NOT followed: a link can leave the
|
|
579
|
+
// repository, and a config outside the repo root selects nothing here.
|
|
580
|
+
if (entry.isDirectory()) {
|
|
581
|
+
if (!CONFIG_SEARCH_PRUNED_DIRS.has(entry.name))
|
|
582
|
+
walk(join(dir, entry.name), depth + 1);
|
|
583
|
+
}
|
|
584
|
+
else if (entry.isFile() && VITEST_CONFIG_NAMES.includes(entry.name)) {
|
|
585
|
+
found.push(join(dir, entry.name));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
walk(cwd, 0);
|
|
590
|
+
return found.sort();
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The config object literal of a runner config file, or null when it is
|
|
594
|
+
* not one literal (`module.exports = merge(a, b)`).
|
|
595
|
+
*/
|
|
596
|
+
function configObjectOf(parsed) {
|
|
597
|
+
let config = null;
|
|
598
|
+
const visit = (node) => {
|
|
599
|
+
if (config !== null)
|
|
600
|
+
return;
|
|
601
|
+
// `export default { … }` — the object literal is the config itself.
|
|
602
|
+
if (ts.isExportAssignment(node) && ts.isObjectLiteralExpression(node.expression)) {
|
|
603
|
+
config = node.expression;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// `export default defineConfig({ … })` / `mergeConfig(a, { … })`:
|
|
607
|
+
// unwrap the first object literal argument.
|
|
608
|
+
if (ts.isExportAssignment(node) &&
|
|
609
|
+
ts.isCallExpression(node.expression) &&
|
|
610
|
+
node.expression.arguments[0] !== undefined &&
|
|
611
|
+
ts.isObjectLiteralExpression(node.expression.arguments[0])) {
|
|
612
|
+
config = node.expression.arguments[0];
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// The CommonJS transpiled shape: `exports.default = { … }`. `=` is the
|
|
616
|
+
// only assignment operator; anything else is a comparison.
|
|
617
|
+
if (ts.isBinaryExpression(node) &&
|
|
618
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
619
|
+
ts.isObjectLiteralExpression(node.right) &&
|
|
620
|
+
ts.isPropertyAccessExpression(node.left) &&
|
|
621
|
+
node.left.name.text === 'default') {
|
|
622
|
+
config = node.right;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
ts.forEachChild(node, visit);
|
|
626
|
+
};
|
|
627
|
+
visit(parsed);
|
|
628
|
+
return config;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* The vitest selection one config file declares, read with the
|
|
632
|
+
* TypeScript AST only — the same bounded, non-executing read the static
|
|
633
|
+
* test scan already performs on candidate source. No consumer code runs
|
|
634
|
+
* and no expression is evaluated.
|
|
635
|
+
*
|
|
636
|
+
* A config that cannot be read as DATA (unreadable file, a computed
|
|
637
|
+
* `include: buildGlobs()`, `mergeConfig(a, b)`) yields NO scope at all:
|
|
638
|
+
* for a foreign runner, claiming nothing leaves the file with the
|
|
639
|
+
* configured runner exactly as before, which is the fail-open answer.
|
|
640
|
+
*
|
|
641
|
+
* The documented defaults idiom — `exclude` set to a spread
|
|
642
|
+
* of `configDefaults.exclude` beside extra string literals,
|
|
643
|
+
* with `configDefaults` imported from `vitest/config` — reads
|
|
644
|
+
* as vitest's own defaults widened by the literals beside it;
|
|
645
|
+
* a same-named local or a foreign module's export does NOT.
|
|
646
|
+
*
|
|
647
|
+
* Args:
|
|
648
|
+
* absolute: absolute path of the config file.
|
|
649
|
+
*
|
|
650
|
+
* Returns:
|
|
651
|
+
* RunnerFileScope | null: the declared selection, or null when this
|
|
652
|
+
* file declares none that can be read (a `vite.config.*` without a
|
|
653
|
+
* `test` block is a BUILD config: it configures no test runner).
|
|
654
|
+
*/
|
|
655
|
+
function vitestScopeOf(absolute) {
|
|
656
|
+
const name = basename(absolute);
|
|
657
|
+
// `vitest.config.*` declares a vitest run even without a `test` block
|
|
658
|
+
// (its defaults then apply); `vite.config.*` only does once it
|
|
659
|
+
// configures `test`.
|
|
660
|
+
const declaresVitest = name.startsWith('vitest.config.');
|
|
661
|
+
let source;
|
|
662
|
+
try {
|
|
663
|
+
source = readFileSync(absolute, 'utf8');
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
const configRoot = dirname(absolute);
|
|
669
|
+
const defaultScope = {
|
|
670
|
+
runner: 'vitest',
|
|
671
|
+
root: configRoot,
|
|
672
|
+
include: [...VITEST_DEFAULT_INCLUDE],
|
|
673
|
+
exclude: [...VITEST_DEFAULT_EXCLUDE],
|
|
674
|
+
globals: false,
|
|
675
|
+
authoritative: true,
|
|
676
|
+
};
|
|
677
|
+
const parsed = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true);
|
|
678
|
+
const config = configObjectOf(parsed);
|
|
679
|
+
const testNode = config === null ? undefined : propertyOf(config, 'test');
|
|
680
|
+
if (testNode === undefined || !ts.isObjectLiteralExpression(testNode)) {
|
|
681
|
+
// A config with no readable `test` block still runs vitest's
|
|
682
|
+
// documented defaults when the file names vitest itself; a
|
|
683
|
+
// `vite.config.*` without `test` configures a bundler, not a runner.
|
|
684
|
+
return declaresVitest ? defaultScope : null;
|
|
685
|
+
}
|
|
686
|
+
const root = literalString(config === null ? undefined : propertyOf(config, 'root'));
|
|
687
|
+
const base = root === undefined ? configRoot : isAbsolute(root) ? root : resolve(configRoot, root);
|
|
688
|
+
const dir = literalString(propertyOf(testNode, 'dir'));
|
|
689
|
+
const scopeRoot = dir === undefined ? base : isAbsolute(dir) ? dir : resolve(base, dir);
|
|
690
|
+
const includeNode = propertyOf(testNode, 'include');
|
|
691
|
+
const excludeNode = propertyOf(testNode, 'exclude');
|
|
692
|
+
const globalsNode = propertyOf(testNode, 'globals');
|
|
693
|
+
const imported = vitestDefaultImportsOf(parsed);
|
|
694
|
+
const include = includeNode === undefined ? [...VITEST_DEFAULT_INCLUDE] : selectionStrings(includeNode, imported);
|
|
695
|
+
const exclude = excludeNode === undefined ? [...VITEST_DEFAULT_EXCLUDE] : selectionStrings(excludeNode, imported);
|
|
696
|
+
const globals = globalsNode === undefined ? false : literalBoolean(globalsNode);
|
|
697
|
+
// A computed selection is not data this boundary may narrow on.
|
|
698
|
+
if (include === undefined || exclude === undefined || globals === undefined)
|
|
699
|
+
return null;
|
|
700
|
+
return { runner: 'vitest', root: scopeRoot, include, exclude, globals, authoritative: true };
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The repository's own vitest test-file selections, read from every
|
|
704
|
+
* vitest config it declares (root first in resolution order, then nested
|
|
705
|
+
* configs, each scoped to its own directory).
|
|
706
|
+
*
|
|
707
|
+
* Args:
|
|
708
|
+
* cwd: absolute repo root.
|
|
709
|
+
*
|
|
710
|
+
* Returns:
|
|
711
|
+
* RunnerFileScope[]: one scope per readable config; empty when the
|
|
712
|
+
* repository declares no vitest selection this boundary can read.
|
|
713
|
+
*/
|
|
714
|
+
export function vitestFileScopes(cwd) {
|
|
715
|
+
const scopes = [];
|
|
716
|
+
for (const absolute of findVitestConfigFiles(cwd)) {
|
|
717
|
+
const scope = vitestScopeOf(absolute);
|
|
718
|
+
if (scope !== null)
|
|
719
|
+
scopes.push(scope);
|
|
720
|
+
}
|
|
721
|
+
return scopes;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Which runner owns a statically discovered file.
|
|
725
|
+
*
|
|
726
|
+
* A foreign scope that is not authoritative claims NOTHING: fail-open
|
|
727
|
+
* for the configured runner means "keep today's behaviour", and a
|
|
728
|
+
* mis-read foreign config must never steal that runner's files.
|
|
729
|
+
*
|
|
730
|
+
* Args:
|
|
731
|
+
* configuredRunner: the repository's configured runner.
|
|
732
|
+
* configuredScopes: that runner's own scopes (empty ⇒ unknown).
|
|
733
|
+
* otherScopes: the other runners' own scopes.
|
|
734
|
+
* cwd: absolute repo root.
|
|
735
|
+
* file: repo-relative posix path.
|
|
736
|
+
*
|
|
737
|
+
* Returns:
|
|
738
|
+
* string | null: the owning runner, or null when no runner's own
|
|
739
|
+
* configuration claims the file — the file is then not that runner's
|
|
740
|
+
* test, and the caller reports it instead of inventing a runner.
|
|
741
|
+
*/
|
|
742
|
+
export function runnerForFile(configuredRunner, configuredScopes, otherScopes, cwd, file) {
|
|
743
|
+
if (anyScopeSelectsFile(configuredScopes, cwd, file))
|
|
744
|
+
return configuredRunner;
|
|
745
|
+
for (const scope of otherScopes) {
|
|
746
|
+
if (!scope.authoritative)
|
|
747
|
+
continue;
|
|
748
|
+
if (scopeSelectsFile(scope, cwd, file))
|
|
749
|
+
return scope.runner;
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
//# sourceMappingURL=runner-file-scope.js.map
|