@elench/testkit 0.1.58 → 0.1.59
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/lib/config/discovery.mjs +61 -32
- package/lib/config/discovery.test.mjs +62 -0
- package/lib/config/index.mjs +29 -1
- package/lib/coverage/index.mjs +34 -32
- package/lib/coverage/index.test.mjs +21 -0
- package/lib/discovery/index.mjs +3 -0
- package/lib/discovery/path-policy.mjs +106 -0
- package/lib/discovery/path-policy.test.mjs +65 -0
- package/lib/setup/index.d.ts +7 -3
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/package.json +3 -3
package/lib/config/discovery.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_DISCOVERY_EXCLUDES,
|
|
5
|
+
normalizeDiscoveryConfig,
|
|
6
|
+
normalizeDiscoveryPath,
|
|
7
|
+
resolveDiscoveryRoots,
|
|
8
|
+
shouldExcludeDiscoveryPath,
|
|
9
|
+
} from "../discovery/path-policy.mjs";
|
|
3
10
|
|
|
4
11
|
const TESTKIT_DIRNAME = "__testkit__";
|
|
5
12
|
const DISCOVERY_RULES = [
|
|
@@ -11,27 +18,10 @@ const DISCOVERY_RULES = [
|
|
|
11
18
|
{ suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
|
|
12
19
|
];
|
|
13
20
|
|
|
14
|
-
const IGNORED_DIRS = new Set([
|
|
15
|
-
".cache",
|
|
16
|
-
".git",
|
|
17
|
-
".hg",
|
|
18
|
-
".next",
|
|
19
|
-
".nuxt",
|
|
20
|
-
".playwright-browsers",
|
|
21
|
-
".svn",
|
|
22
|
-
".testkit",
|
|
23
|
-
".turbo",
|
|
24
|
-
"build",
|
|
25
|
-
"coverage",
|
|
26
|
-
"dist",
|
|
27
|
-
"node_modules",
|
|
28
|
-
"playwright-report",
|
|
29
|
-
"test-results",
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
21
|
export function discoverProject(productDir, explicitServices = {}, options = {}) {
|
|
33
22
|
const strict = options.strict !== false;
|
|
34
|
-
const
|
|
23
|
+
const repoDiscovery = normalizeDiscoveryConfig(options.discovery, { allowRoots: true });
|
|
24
|
+
const { suiteFiles, legacyFiles } = discoverFiles(productDir, repoDiscovery);
|
|
35
25
|
const groupedByService = {};
|
|
36
26
|
const services = {};
|
|
37
27
|
const diagnostics = buildLegacyFileDiagnostics(legacyFiles);
|
|
@@ -41,7 +31,8 @@ export function discoverProject(productDir, explicitServices = {}, options = {})
|
|
|
41
31
|
const rule = inferRule(filePath);
|
|
42
32
|
if (!rule) continue;
|
|
43
33
|
|
|
44
|
-
const owners = inferOwners(filePath, explicitServices);
|
|
34
|
+
const owners = inferOwners(filePath, explicitServices, repoDiscovery);
|
|
35
|
+
if (owners === null) continue;
|
|
45
36
|
if (owners.length === 0) {
|
|
46
37
|
diagnostics.push({
|
|
47
38
|
code: "unowned_test",
|
|
@@ -185,10 +176,16 @@ export function buildDiscoveryErrorFromDiagnostics(diagnostics = []) {
|
|
|
185
176
|
return new Error(lines.join("\n"));
|
|
186
177
|
}
|
|
187
178
|
|
|
188
|
-
function discoverFiles(productDir) {
|
|
179
|
+
function discoverFiles(productDir, repoDiscovery = {}) {
|
|
189
180
|
const suiteFiles = [];
|
|
190
181
|
const legacyFiles = [];
|
|
191
|
-
const
|
|
182
|
+
const exclude = [
|
|
183
|
+
...new Set([
|
|
184
|
+
...DEFAULT_DISCOVERY_EXCLUDES,
|
|
185
|
+
...((repoDiscovery.exclude || []).map(normalizePath)),
|
|
186
|
+
]),
|
|
187
|
+
];
|
|
188
|
+
const queue = resolveDiscoveryRoots(productDir, repoDiscovery.roots, ".");
|
|
192
189
|
|
|
193
190
|
while (queue.length > 0) {
|
|
194
191
|
const current = queue.pop();
|
|
@@ -198,7 +195,8 @@ function discoverFiles(productDir) {
|
|
|
198
195
|
if (entry.isSymbolicLink()) continue;
|
|
199
196
|
|
|
200
197
|
if (entry.isDirectory()) {
|
|
201
|
-
|
|
198
|
+
const relativeDirPath = normalizeDiscoveryPath(path.relative(productDir, absolutePath));
|
|
199
|
+
if (shouldExcludeDiscoveryPath(relativeDirPath, exclude)) continue;
|
|
202
200
|
queue.push(absolutePath);
|
|
203
201
|
continue;
|
|
204
202
|
}
|
|
@@ -221,9 +219,9 @@ function discoverFiles(productDir) {
|
|
|
221
219
|
};
|
|
222
220
|
}
|
|
223
221
|
|
|
224
|
-
function inferOwners(filePath, explicitServices) {
|
|
222
|
+
function inferOwners(filePath, explicitServices, repoDiscovery = {}) {
|
|
225
223
|
const serviceRules = Object.entries(explicitServices).flatMap(([name, config]) =>
|
|
226
|
-
buildServiceRules(name, config)
|
|
224
|
+
buildServiceRules(name, config, repoDiscovery)
|
|
227
225
|
);
|
|
228
226
|
if (serviceRules.length === 0) {
|
|
229
227
|
return [
|
|
@@ -235,8 +233,14 @@ function inferOwners(filePath, explicitServices) {
|
|
|
235
233
|
];
|
|
236
234
|
}
|
|
237
235
|
|
|
238
|
-
const
|
|
236
|
+
const relevantRules = serviceRules.filter(
|
|
237
|
+
(rule) => rule.depth === 0 || (rule.matchPrefix && hasPrefix(filePath, rule.matchPrefix))
|
|
238
|
+
);
|
|
239
|
+
const owningRules = relevantRules.filter((rule) => ownsFile(rule, filePath));
|
|
239
240
|
if (owningRules.length === 0) {
|
|
241
|
+
if (relevantRules.length > 0 && relevantRules.every((rule) => isExcludedForServiceRule(rule, filePath))) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
240
244
|
return [];
|
|
241
245
|
}
|
|
242
246
|
|
|
@@ -291,37 +295,62 @@ function disambiguateSuiteNames(suites) {
|
|
|
291
295
|
return resolved;
|
|
292
296
|
}
|
|
293
297
|
|
|
294
|
-
function buildServiceRules(serviceName, serviceConfig) {
|
|
298
|
+
function buildServiceRules(serviceName, serviceConfig, repoDiscovery = {}) {
|
|
295
299
|
const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
: [
|
|
300
|
+
const serviceDiscovery = normalizeDiscoveryConfig(serviceConfig?.discovery, { allowRoots: true });
|
|
301
|
+
const discoveryRoots =
|
|
302
|
+
Array.isArray(serviceDiscovery.roots) && serviceDiscovery.roots.length > 0 ? serviceDiscovery.roots : ["."];
|
|
303
|
+
const serviceRootRelativeExcludes = (serviceDiscovery.exclude || []).map((entry) =>
|
|
304
|
+
cwd === "." ? normalizePath(entry) : normalizePath(path.posix.join(cwd, entry))
|
|
305
|
+
);
|
|
306
|
+
const exclude = [
|
|
307
|
+
...new Set([
|
|
308
|
+
...DEFAULT_DISCOVERY_EXCLUDES,
|
|
309
|
+
...((repoDiscovery.exclude || []).map(normalizePath)),
|
|
310
|
+
...serviceRootRelativeExcludes,
|
|
311
|
+
]),
|
|
312
|
+
];
|
|
299
313
|
|
|
300
314
|
return discoveryRoots.map((root) => {
|
|
301
315
|
const normalizedRoot = normalizePath(root || ".");
|
|
316
|
+
const matchPrefix =
|
|
317
|
+
cwd === "."
|
|
318
|
+
? normalizedRoot
|
|
319
|
+
: normalizedRoot === "."
|
|
320
|
+
? cwd
|
|
321
|
+
: normalizePath(path.posix.join(cwd, normalizedRoot));
|
|
302
322
|
return {
|
|
303
323
|
name: serviceName,
|
|
304
324
|
source: "cwd",
|
|
305
325
|
cwdPrefix: cwd === "." ? null : cwd,
|
|
306
|
-
matchPrefix:
|
|
307
|
-
depth:
|
|
326
|
+
matchPrefix: matchPrefix === "." ? null : matchPrefix,
|
|
327
|
+
depth: matchPrefix === "." ? 0 : matchPrefix.split("/").filter(Boolean).length,
|
|
328
|
+
exclude,
|
|
308
329
|
};
|
|
309
330
|
});
|
|
310
331
|
}
|
|
311
332
|
|
|
312
333
|
function ownsFile(serviceRule, filePath) {
|
|
334
|
+
if (isExcludedForServiceRule(serviceRule, filePath)) return false;
|
|
313
335
|
if (serviceRule.depth === 0) return true;
|
|
314
336
|
if (serviceRule.matchPrefix && hasPrefix(filePath, serviceRule.matchPrefix)) return true;
|
|
315
337
|
return false;
|
|
316
338
|
}
|
|
317
339
|
|
|
318
340
|
function relativeToServiceRoot(serviceRule, filePath) {
|
|
341
|
+
if (serviceRule.matchPrefix && hasPrefix(filePath, serviceRule.matchPrefix)) {
|
|
342
|
+
return path.posix.relative(serviceRule.matchPrefix, filePath);
|
|
343
|
+
}
|
|
319
344
|
if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
|
|
320
345
|
return path.posix.relative(serviceRule.cwdPrefix, filePath);
|
|
321
346
|
}
|
|
322
347
|
return filePath;
|
|
323
348
|
}
|
|
324
349
|
|
|
350
|
+
function isExcludedForServiceRule(serviceRule, filePath) {
|
|
351
|
+
return shouldExcludeDiscoveryPath(normalizePath(filePath), serviceRule.exclude || []);
|
|
352
|
+
}
|
|
353
|
+
|
|
325
354
|
function deriveSuiteRef(relativePath, suffix) {
|
|
326
355
|
const parts = relativePath.split("/").filter(Boolean);
|
|
327
356
|
const testkitIndex = parts.indexOf(TESTKIT_DIRNAME);
|
|
@@ -205,6 +205,68 @@ describe("filesystem-discovery", () => {
|
|
|
205
205
|
]);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
it("does not treat nested coverage source directories as generated output", () => {
|
|
209
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
210
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
211
|
+
|
|
212
|
+
writeFile(productDir, "app/coverage/__testkit__/coverage-map.pw.testkit.ts");
|
|
213
|
+
|
|
214
|
+
const project = discoverProject(productDir, {
|
|
215
|
+
web: {
|
|
216
|
+
local: {
|
|
217
|
+
cwd: ".",
|
|
218
|
+
},
|
|
219
|
+
discovery: {
|
|
220
|
+
roots: ["app"],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(project.files).toEqual([
|
|
226
|
+
{
|
|
227
|
+
serviceName: "web",
|
|
228
|
+
type: "e2e",
|
|
229
|
+
framework: "playwright",
|
|
230
|
+
suiteName: "coverage",
|
|
231
|
+
suitePath: ["coverage"],
|
|
232
|
+
filePath: "app/coverage/__testkit__/coverage-map.pw.testkit.ts",
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("supports repo-level and service-level discovery excludes", () => {
|
|
238
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
239
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
240
|
+
|
|
241
|
+
writeFile(productDir, "coverage/__testkit__/noise/noise.pw.testkit.ts");
|
|
242
|
+
writeFile(productDir, "app/generated/__testkit__/noise/noise.pw.testkit.ts");
|
|
243
|
+
writeFile(productDir, "app/coverage/__testkit__/coverage-map.pw.testkit.ts");
|
|
244
|
+
|
|
245
|
+
const project = discoverProject(
|
|
246
|
+
productDir,
|
|
247
|
+
{
|
|
248
|
+
web: {
|
|
249
|
+
local: {
|
|
250
|
+
cwd: ".",
|
|
251
|
+
},
|
|
252
|
+
discovery: {
|
|
253
|
+
roots: ["app"],
|
|
254
|
+
exclude: ["app/generated"],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
discovery: {
|
|
260
|
+
exclude: ["coverage"],
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(project.files.map((entry) => entry.filePath)).toEqual([
|
|
266
|
+
"app/coverage/__testkit__/coverage-map.pw.testkit.ts",
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
208
270
|
});
|
|
209
271
|
|
|
210
272
|
function writeFile(productDir, relativePath, content = "export {};\n") {
|
package/lib/config/index.mjs
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
normalizeRuntimeToolchain,
|
|
21
21
|
normalizeToolchainRegistry,
|
|
22
22
|
} from "../toolchains/index.mjs";
|
|
23
|
+
import {
|
|
24
|
+
mergeDiscoveryConfigs,
|
|
25
|
+
normalizeDiscoveryConfig,
|
|
26
|
+
} from "../discovery/path-policy.mjs";
|
|
23
27
|
|
|
24
28
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
25
29
|
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
@@ -38,8 +42,12 @@ export async function loadConfigContext(opts = {}) {
|
|
|
38
42
|
const execution = normalizeRepoExecution(setup.execution);
|
|
39
43
|
const reporting = normalizeReportingConfig(setup.reporting);
|
|
40
44
|
const toolchains = normalizeToolchainRegistry(setup.toolchains);
|
|
45
|
+
const discoveryConfig = normalizeRepoDiscoveryConfig(setup.discovery);
|
|
41
46
|
const explicitServices = setup.services || {};
|
|
42
|
-
const discovery = discoverProject(productDir, explicitServices,
|
|
47
|
+
const discovery = discoverProject(productDir, explicitServices, {
|
|
48
|
+
...(opts.discoveryOptions || {}),
|
|
49
|
+
discovery: discoveryConfig,
|
|
50
|
+
});
|
|
43
51
|
const serviceNames = new Set([
|
|
44
52
|
...Object.keys(explicitServices),
|
|
45
53
|
...Object.keys(discovery.suitesByService),
|
|
@@ -54,6 +62,7 @@ export async function loadConfigContext(opts = {}) {
|
|
|
54
62
|
setup,
|
|
55
63
|
setupFile,
|
|
56
64
|
execution,
|
|
65
|
+
discovery: discoveryConfig,
|
|
57
66
|
reporting,
|
|
58
67
|
toolchains,
|
|
59
68
|
explicitService: explicitServices[name] || {},
|
|
@@ -69,6 +78,7 @@ export async function loadConfigContext(opts = {}) {
|
|
|
69
78
|
setup,
|
|
70
79
|
setupFile,
|
|
71
80
|
execution,
|
|
81
|
+
discovery: discoveryConfig,
|
|
72
82
|
reporting,
|
|
73
83
|
toolchains,
|
|
74
84
|
explicitServices,
|
|
@@ -132,6 +142,7 @@ function normalizeServiceConfig({
|
|
|
132
142
|
setup,
|
|
133
143
|
setupFile,
|
|
134
144
|
execution,
|
|
145
|
+
discovery,
|
|
135
146
|
reporting,
|
|
136
147
|
toolchains,
|
|
137
148
|
explicitService,
|
|
@@ -145,6 +156,10 @@ function normalizeServiceConfig({
|
|
|
145
156
|
...(explicitService.env || {}),
|
|
146
157
|
};
|
|
147
158
|
const browser = normalizeBrowserServiceConfig(explicitService.browser, name);
|
|
159
|
+
const normalizedDiscovery = mergeDiscoveryConfigs(
|
|
160
|
+
discovery,
|
|
161
|
+
normalizeServiceDiscoveryConfig(explicitService.discovery, name)
|
|
162
|
+
);
|
|
148
163
|
if (explicitService.migrate || explicitService.seed) {
|
|
149
164
|
throw new Error(
|
|
150
165
|
`Service "${name}" uses removed migrate/seed hooks. Move template lifecycle to database.template.{migrate,seed,verify}.`
|
|
@@ -184,6 +199,7 @@ function normalizeServiceConfig({
|
|
|
184
199
|
execution,
|
|
185
200
|
reporting,
|
|
186
201
|
dependsOn: explicitService.dependsOn || [],
|
|
202
|
+
discovery: normalizedDiscovery,
|
|
187
203
|
database,
|
|
188
204
|
databaseFrom: explicitService.databaseFrom,
|
|
189
205
|
envFiles,
|
|
@@ -197,6 +213,18 @@ function normalizeServiceConfig({
|
|
|
197
213
|
};
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
function normalizeRepoDiscoveryConfig(value) {
|
|
217
|
+
return normalizeDiscoveryConfig(value, { allowRoots: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeServiceDiscoveryConfig(value, serviceName) {
|
|
221
|
+
try {
|
|
222
|
+
return normalizeDiscoveryConfig(value, { allowRoots: true });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new Error(`Service "${serviceName}" has invalid discovery config: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
200
228
|
function normalizeReportingConfig(value) {
|
|
201
229
|
if (!value) return null;
|
|
202
230
|
|
package/lib/coverage/index.mjs
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { TESTKIT_COVERAGE_GRAPH_VERSION } from "@elench/testkit-protocol";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
".nuxt",
|
|
11
|
-
".playwright-browsers",
|
|
12
|
-
".svn",
|
|
13
|
-
".testkit",
|
|
14
|
-
".turbo",
|
|
15
|
-
"build",
|
|
16
|
-
"coverage",
|
|
17
|
-
"dist",
|
|
18
|
-
"node_modules",
|
|
19
|
-
"playwright-report",
|
|
20
|
-
"test-results",
|
|
21
|
-
]);
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_DISCOVERY_EXCLUDES,
|
|
6
|
+
normalizeDiscoveryConfig,
|
|
7
|
+
normalizeDiscoveryPath,
|
|
8
|
+
shouldExcludeDiscoveryPath,
|
|
9
|
+
} from "../discovery/path-policy.mjs";
|
|
22
10
|
|
|
23
11
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
24
12
|
const HTTP_WRAPPER_METHODS = {
|
|
@@ -29,12 +17,13 @@ const HTTP_WRAPPER_METHODS = {
|
|
|
29
17
|
deleteJson: "DELETE",
|
|
30
18
|
};
|
|
31
19
|
|
|
32
|
-
export function buildCoverageGraph({ productDir, services = {}, discoveryFiles = [] }) {
|
|
20
|
+
export function buildCoverageGraph({ productDir, repoDiscovery = {}, services = {}, discoveryFiles = [] }) {
|
|
33
21
|
const graph = createEmptyGraph();
|
|
34
22
|
const serviceContexts = new Map();
|
|
23
|
+
const normalizedRepoDiscovery = normalizeDiscoveryConfig(repoDiscovery, { allowRoots: true });
|
|
35
24
|
|
|
36
25
|
for (const [serviceName, config] of Object.entries(services)) {
|
|
37
|
-
const context = buildServiceCoverageContext(productDir, serviceName, config);
|
|
26
|
+
const context = buildServiceCoverageContext(productDir, serviceName, config, normalizedRepoDiscovery);
|
|
38
27
|
if (!context) continue;
|
|
39
28
|
serviceContexts.set(serviceName, context);
|
|
40
29
|
appendGraph(graph, context.graph);
|
|
@@ -105,15 +94,23 @@ export function buildCoverageGraph({ productDir, services = {}, discoveryFiles =
|
|
|
105
94
|
return graph;
|
|
106
95
|
}
|
|
107
96
|
|
|
108
|
-
export function buildServiceCoverageContext(productDir, serviceName, config) {
|
|
97
|
+
export function buildServiceCoverageContext(productDir, serviceName, config, repoDiscovery = {}) {
|
|
109
98
|
const serviceRoot = resolveServiceRoot(productDir, config);
|
|
110
99
|
const nextAppRoot = findNextAppRoot(serviceRoot);
|
|
111
100
|
if (!nextAppRoot) return null;
|
|
101
|
+
const serviceDiscovery = normalizeDiscoveryConfig(config?.discovery, { allowRoots: true });
|
|
102
|
+
const exclude = [
|
|
103
|
+
...new Set([
|
|
104
|
+
...DEFAULT_DISCOVERY_EXCLUDES,
|
|
105
|
+
...((repoDiscovery.exclude || []).map(normalizePath)),
|
|
106
|
+
...((serviceDiscovery.exclude || []).map(normalizePath)),
|
|
107
|
+
]),
|
|
108
|
+
];
|
|
112
109
|
|
|
113
110
|
const graph = createEmptyGraph();
|
|
114
|
-
const pages = discoverPageViews(serviceName, serviceRoot, nextAppRoot);
|
|
115
|
-
const apiRoutes = discoverApiRoutes(serviceName, serviceRoot, nextAppRoot);
|
|
116
|
-
const serverActions = discoverServerActions(serviceName, serviceRoot);
|
|
111
|
+
const pages = discoverPageViews(serviceName, serviceRoot, nextAppRoot, exclude);
|
|
112
|
+
const apiRoutes = discoverApiRoutes(serviceName, serviceRoot, nextAppRoot, exclude);
|
|
113
|
+
const serverActions = discoverServerActions(serviceName, serviceRoot, exclude);
|
|
117
114
|
|
|
118
115
|
for (const node of [...pages.nodes, ...apiRoutes.nodes, ...serverActions.nodes]) {
|
|
119
116
|
graph.nodes.push(node);
|
|
@@ -261,8 +258,10 @@ function buildEvidenceDetails(coveredNodeIds, graph) {
|
|
|
261
258
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
262
259
|
}
|
|
263
260
|
|
|
264
|
-
function discoverPageViews(serviceName, serviceRoot, nextAppRoot) {
|
|
265
|
-
const pageFiles = walkFiles(nextAppRoot
|
|
261
|
+
function discoverPageViews(serviceName, serviceRoot, nextAppRoot, exclude = []) {
|
|
262
|
+
const pageFiles = walkFiles(nextAppRoot, { baseDir: serviceRoot, exclude }).filter(
|
|
263
|
+
(filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts")
|
|
264
|
+
);
|
|
266
265
|
const nodes = [];
|
|
267
266
|
const edges = [];
|
|
268
267
|
const pageEntries = [];
|
|
@@ -300,8 +299,8 @@ function discoverPageViews(serviceName, serviceRoot, nextAppRoot) {
|
|
|
300
299
|
return { nodes, edges, pageEntries };
|
|
301
300
|
}
|
|
302
301
|
|
|
303
|
-
function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot) {
|
|
304
|
-
const routeFiles = walkFiles(path.join(nextAppRoot, "api")).filter(
|
|
302
|
+
function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot, exclude = []) {
|
|
303
|
+
const routeFiles = walkFiles(path.join(nextAppRoot, "api"), { baseDir: serviceRoot, exclude }).filter(
|
|
305
304
|
(filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")
|
|
306
305
|
);
|
|
307
306
|
const nodes = [];
|
|
@@ -348,7 +347,7 @@ function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot) {
|
|
|
348
347
|
return { nodes: dedupeNodes(nodes), edges, routeEntries };
|
|
349
348
|
}
|
|
350
349
|
|
|
351
|
-
function discoverServerActions(serviceName, serviceRoot) {
|
|
350
|
+
function discoverServerActions(serviceName, serviceRoot, exclude = []) {
|
|
352
351
|
const appRoots = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")].filter((candidate) =>
|
|
353
352
|
fs.existsSync(candidate)
|
|
354
353
|
);
|
|
@@ -357,7 +356,7 @@ function discoverServerActions(serviceName, serviceRoot) {
|
|
|
357
356
|
const actionEntries = [];
|
|
358
357
|
|
|
359
358
|
for (const appRoot of appRoots) {
|
|
360
|
-
for (const absolutePath of walkFiles(appRoot)) {
|
|
359
|
+
for (const absolutePath of walkFiles(appRoot, { baseDir: serviceRoot, exclude })) {
|
|
361
360
|
if (!absolutePath.endsWith(".ts") && !absolutePath.endsWith(".tsx")) continue;
|
|
362
361
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
363
362
|
if (!isServerActionFile(content)) continue;
|
|
@@ -630,8 +629,10 @@ function resolveServiceRoot(productDir, config) {
|
|
|
630
629
|
return path.resolve(productDir, cwd);
|
|
631
630
|
}
|
|
632
631
|
|
|
633
|
-
function walkFiles(rootDir) {
|
|
632
|
+
function walkFiles(rootDir, options = {}) {
|
|
634
633
|
if (!fs.existsSync(rootDir)) return [];
|
|
634
|
+
const baseDir = options.baseDir || rootDir;
|
|
635
|
+
const exclude = options.exclude || [];
|
|
635
636
|
const results = [];
|
|
636
637
|
const queue = [rootDir];
|
|
637
638
|
while (queue.length > 0) {
|
|
@@ -640,7 +641,8 @@ function walkFiles(rootDir) {
|
|
|
640
641
|
if (entry.isSymbolicLink()) continue;
|
|
641
642
|
const absolutePath = path.join(current, entry.name);
|
|
642
643
|
if (entry.isDirectory()) {
|
|
643
|
-
|
|
644
|
+
const relativeDirPath = normalizeDiscoveryPath(path.relative(baseDir, absolutePath));
|
|
645
|
+
if (shouldExcludeDiscoveryPath(relativeDirPath, exclude)) continue;
|
|
644
646
|
queue.push(absolutePath);
|
|
645
647
|
continue;
|
|
646
648
|
}
|
|
@@ -205,6 +205,27 @@ describe("coverage graph builder", () => {
|
|
|
205
205
|
expect(context.pageByRoute.get("/projects/[projectId]").node).toBeTruthy();
|
|
206
206
|
expect(context.apiRouteByKey.get("GET:/api/projects/[projectId]").node).toBeTruthy();
|
|
207
207
|
});
|
|
208
|
+
|
|
209
|
+
it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
|
|
210
|
+
const productDir = createProduct();
|
|
211
|
+
writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { return null; }`);
|
|
212
|
+
writeFile(productDir, "coverage/page.tsx", `export default function OutputPage() { return null; }`);
|
|
213
|
+
|
|
214
|
+
const context = buildServiceCoverageContext(
|
|
215
|
+
productDir,
|
|
216
|
+
"web",
|
|
217
|
+
{
|
|
218
|
+
local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
|
|
219
|
+
discovery: { roots: ["app"] },
|
|
220
|
+
},
|
|
221
|
+
{ exclude: ["coverage"] }
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(context.pageByRoute.get("/coverage").node).toMatchObject({
|
|
225
|
+
filePath: "app/coverage/page.tsx",
|
|
226
|
+
});
|
|
227
|
+
expect(context.graph.nodes.some((node) => node.filePath === "coverage/page.tsx")).toBe(false);
|
|
228
|
+
});
|
|
208
229
|
});
|
|
209
230
|
|
|
210
231
|
function createProduct() {
|
package/lib/discovery/index.mjs
CHANGED
|
@@ -52,6 +52,7 @@ export async function discoverTests(options = {}) {
|
|
|
52
52
|
|
|
53
53
|
const rawDiscovery = discoverProject(productDir, setupContext.setup.services || {}, {
|
|
54
54
|
strict: filters.diagnosticsMode === "error",
|
|
55
|
+
discovery: setupContext.setup.discovery || {},
|
|
55
56
|
});
|
|
56
57
|
baseResult.diagnostics.push(...rawDiscovery.diagnostics);
|
|
57
58
|
validateRequestedService(filters.serviceFilter, setupContext.setup.services || {}, rawDiscovery);
|
|
@@ -90,6 +91,7 @@ export async function discoverTests(options = {}) {
|
|
|
90
91
|
files: resolved.files,
|
|
91
92
|
coverageGraph: buildCoverageGraph({
|
|
92
93
|
productDir,
|
|
94
|
+
repoDiscovery: setupContext.setup.discovery || {},
|
|
93
95
|
services: setupContext.setup.services || {},
|
|
94
96
|
discoveryFiles: rawDiscovery.files || [],
|
|
95
97
|
}),
|
|
@@ -111,6 +113,7 @@ export async function discoverTests(options = {}) {
|
|
|
111
113
|
files: rawOnly.files,
|
|
112
114
|
coverageGraph: buildCoverageGraph({
|
|
113
115
|
productDir,
|
|
116
|
+
repoDiscovery: setupContext.setup.discovery || {},
|
|
114
117
|
services: setupContext.setup.services || {},
|
|
115
118
|
discoveryFiles: rawDiscovery.files || [],
|
|
116
119
|
}),
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_DISCOVERY_EXCLUDES = Object.freeze([
|
|
5
|
+
".cache",
|
|
6
|
+
".git",
|
|
7
|
+
".hg",
|
|
8
|
+
".next",
|
|
9
|
+
".nuxt",
|
|
10
|
+
".playwright-browsers",
|
|
11
|
+
".svn",
|
|
12
|
+
".testkit",
|
|
13
|
+
".turbo",
|
|
14
|
+
"build",
|
|
15
|
+
"coverage",
|
|
16
|
+
"dist",
|
|
17
|
+
"node_modules",
|
|
18
|
+
"playwright-report",
|
|
19
|
+
"test-results",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function normalizeDiscoveryConfig(value, { allowRoots = true } = {}) {
|
|
23
|
+
if (value == null) return {};
|
|
24
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
25
|
+
throw new Error("testkit discovery config must be an object");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const normalized = {};
|
|
29
|
+
if (allowRoots && Object.prototype.hasOwnProperty.call(value, "roots")) {
|
|
30
|
+
normalized.roots = normalizeDiscoveryPathList(value.roots, "testkit discovery roots");
|
|
31
|
+
}
|
|
32
|
+
if (Object.prototype.hasOwnProperty.call(value, "exclude")) {
|
|
33
|
+
normalized.exclude = normalizeDiscoveryPathList(value.exclude, "testkit discovery exclude");
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mergeDiscoveryConfigs(...configs) {
|
|
39
|
+
const merged = {};
|
|
40
|
+
for (const config of configs.filter(Boolean)) {
|
|
41
|
+
if (Array.isArray(config.roots)) {
|
|
42
|
+
merged.roots = [...config.roots];
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(config.exclude) && config.exclude.length > 0) {
|
|
45
|
+
merged.exclude = [...new Set([...(merged.exclude || []), ...config.exclude])];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveDiscoveryRoots(baseDir, roots, defaultRoot = ".") {
|
|
52
|
+
const normalizedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [defaultRoot];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const resolved = [];
|
|
55
|
+
|
|
56
|
+
for (const root of normalizedRoots) {
|
|
57
|
+
const normalizedRoot = normalizeDiscoveryPath(root);
|
|
58
|
+
if (!normalizedRoot) continue;
|
|
59
|
+
const absolutePath = path.resolve(baseDir, normalizedRoot === "." ? "." : normalizedRoot);
|
|
60
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) continue;
|
|
61
|
+
if (seen.has(absolutePath)) continue;
|
|
62
|
+
seen.add(absolutePath);
|
|
63
|
+
resolved.push(absolutePath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return resolved.sort((left, right) => left.localeCompare(right));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function shouldExcludeDiscoveryPath(relativePath, exclude = []) {
|
|
70
|
+
const normalizedPath = normalizeDiscoveryPath(relativePath);
|
|
71
|
+
if (!normalizedPath || normalizedPath === ".") return false;
|
|
72
|
+
return exclude.some((pattern) => matchesDiscoveryPathPattern(normalizedPath, pattern));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeDiscoveryPath(value) {
|
|
76
|
+
const normalized = String(value || "")
|
|
77
|
+
.trim()
|
|
78
|
+
.split(path.sep)
|
|
79
|
+
.join("/")
|
|
80
|
+
.replace(/^\.\/+/u, "")
|
|
81
|
+
.replace(/\/+/gu, "/")
|
|
82
|
+
.replace(/\/+$/u, "");
|
|
83
|
+
return normalized || ".";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeDiscoveryPathList(value, label) {
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
throw new Error(`${label} must be an array of relative paths`);
|
|
89
|
+
}
|
|
90
|
+
const normalized = value
|
|
91
|
+
.map((entry) => {
|
|
92
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
93
|
+
throw new Error(`${label} entries must be non-empty strings`);
|
|
94
|
+
}
|
|
95
|
+
if (path.isAbsolute(entry)) {
|
|
96
|
+
throw new Error(`${label} entries must be relative paths`);
|
|
97
|
+
}
|
|
98
|
+
return normalizeDiscoveryPath(entry);
|
|
99
|
+
})
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
return [...new Set(normalized)];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchesDiscoveryPathPattern(relativePath, pattern) {
|
|
105
|
+
return relativePath === pattern || relativePath.startsWith(`${pattern}/`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
mergeDiscoveryConfigs,
|
|
7
|
+
normalizeDiscoveryConfig,
|
|
8
|
+
resolveDiscoveryRoots,
|
|
9
|
+
shouldExcludeDiscoveryPath,
|
|
10
|
+
} from "./path-policy.mjs";
|
|
11
|
+
|
|
12
|
+
const cleanups = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
while (cleanups.length > 0) {
|
|
16
|
+
cleanups.pop()();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("discovery path policy", () => {
|
|
21
|
+
it("normalizes roots and excludes as relative paths", () => {
|
|
22
|
+
expect(
|
|
23
|
+
normalizeDiscoveryConfig({
|
|
24
|
+
roots: ["./app/", "src/app"],
|
|
25
|
+
exclude: ["./coverage/", "dist"],
|
|
26
|
+
})
|
|
27
|
+
).toEqual({
|
|
28
|
+
roots: ["app", "src/app"],
|
|
29
|
+
exclude: ["coverage", "dist"],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("merges excludes without losing later roots", () => {
|
|
34
|
+
expect(
|
|
35
|
+
mergeDiscoveryConfigs(
|
|
36
|
+
{ roots: ["src"], exclude: ["coverage"] },
|
|
37
|
+
{ exclude: ["dist"] },
|
|
38
|
+
{ roots: ["app"], exclude: ["test-results"] }
|
|
39
|
+
)
|
|
40
|
+
).toEqual({
|
|
41
|
+
roots: ["app"],
|
|
42
|
+
exclude: ["coverage", "dist", "test-results"],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("treats exclude paths as path prefixes rather than bare directory names", () => {
|
|
47
|
+
expect(shouldExcludeDiscoveryPath("coverage", ["coverage"])).toBe(true);
|
|
48
|
+
expect(shouldExcludeDiscoveryPath("coverage/report/index.html", ["coverage"])).toBe(true);
|
|
49
|
+
expect(shouldExcludeDiscoveryPath("app/coverage", ["coverage"])).toBe(false);
|
|
50
|
+
expect(shouldExcludeDiscoveryPath("src/app/coverage", ["coverage"])).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves only existing discovery roots", () => {
|
|
54
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-roots-"));
|
|
55
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
56
|
+
fs.mkdirSync(path.join(productDir, "app"), { recursive: true });
|
|
57
|
+
fs.mkdirSync(path.join(productDir, "src", "app"), { recursive: true });
|
|
58
|
+
|
|
59
|
+
const roots = resolveDiscoveryRoots(productDir, ["app", "missing", "src/app"]);
|
|
60
|
+
expect(roots).toEqual([
|
|
61
|
+
path.join(productDir, "app"),
|
|
62
|
+
path.join(productDir, "src", "app"),
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/lib/setup/index.d.ts
CHANGED
|
@@ -116,13 +116,16 @@ export interface KnownFailureIssueValidationConfig {
|
|
|
116
116
|
cacheTtlSeconds?: number;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export interface DiscoveryConfig {
|
|
120
|
+
roots?: string[];
|
|
121
|
+
exclude?: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
119
124
|
export interface ServiceConfig {
|
|
120
125
|
database?: LocalDatabaseConfig;
|
|
121
126
|
databaseFrom?: string;
|
|
122
127
|
dependsOn?: string[];
|
|
123
|
-
discovery?:
|
|
124
|
-
roots?: string[];
|
|
125
|
-
};
|
|
128
|
+
discovery?: DiscoveryConfig;
|
|
126
129
|
env?: Record<string, string>;
|
|
127
130
|
envFile?: string;
|
|
128
131
|
envFiles?: string[];
|
|
@@ -142,6 +145,7 @@ export interface ServiceConfig {
|
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
export interface TestkitSetup {
|
|
148
|
+
discovery?: DiscoveryConfig;
|
|
145
149
|
execution?: TestkitExecutionConfig;
|
|
146
150
|
profiles?: {
|
|
147
151
|
http?: Record<string, HttpSuiteConfig>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@elench/testkit-protocol": "0.1.
|
|
14
|
+
"@elench/testkit-protocol": "0.1.59"
|
|
15
15
|
},
|
|
16
16
|
"private": false
|
|
17
17
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"vitest": "^3.2.4"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@elench/testkit-bridge": "0.1.
|
|
63
|
-
"@elench/testkit-protocol": "0.1.
|
|
62
|
+
"@elench/testkit-bridge": "0.1.59",
|
|
63
|
+
"@elench/testkit-protocol": "0.1.59",
|
|
64
64
|
"@babel/code-frame": "^7.29.0",
|
|
65
65
|
"@oclif/core": "^4.10.6",
|
|
66
66
|
"esbuild": "^0.25.11",
|