@elench/testkit 0.1.58 → 0.1.60
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 +76 -34
- package/lib/coverage/index.test.mjs +42 -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-bridge/src/index.mjs +19 -2
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +6 -1
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +1 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +3 -1
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +14 -0
- 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);
|
|
@@ -95,7 +84,7 @@ export function buildCoverageGraph({ productDir, services = {}, discoveryFiles =
|
|
|
95
84
|
framework: entry.framework,
|
|
96
85
|
testFilePath: entry.filePath,
|
|
97
86
|
coveredNodeIds,
|
|
98
|
-
details: buildEvidenceDetails(coveredNodeIds, graph),
|
|
87
|
+
details: buildEvidenceDetails(coveredNodeIds, graph, entry, context),
|
|
99
88
|
});
|
|
100
89
|
}
|
|
101
90
|
|
|
@@ -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);
|
|
@@ -241,7 +238,7 @@ function inferCoveredNodeIdsForTest(entry, context) {
|
|
|
241
238
|
return [...coveredNodeIds].sort();
|
|
242
239
|
}
|
|
243
240
|
|
|
244
|
-
function buildEvidenceDetails(coveredNodeIds, graph) {
|
|
241
|
+
function buildEvidenceDetails(coveredNodeIds, graph, entry, context) {
|
|
245
242
|
const routes = new Set();
|
|
246
243
|
const requestPaths = new Set();
|
|
247
244
|
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
@@ -258,11 +255,43 @@ function buildEvidenceDetails(coveredNodeIds, graph) {
|
|
|
258
255
|
if (requestPaths.size > 0) {
|
|
259
256
|
details.requestPaths = [...requestPaths].sort();
|
|
260
257
|
}
|
|
258
|
+
const targets = extractPlaywrightTargets(entry, context);
|
|
259
|
+
if (targets.length > 0) {
|
|
260
|
+
details.targets = targets;
|
|
261
|
+
}
|
|
261
262
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
function
|
|
265
|
-
|
|
265
|
+
function extractPlaywrightTargets(entry, context) {
|
|
266
|
+
if (!entry || entry.framework !== "playwright" || !context?.serviceRoot) return [];
|
|
267
|
+
const absolutePath = path.join(context.serviceRoot, entry.filePath);
|
|
268
|
+
if (!fs.existsSync(absolutePath)) return [];
|
|
269
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
270
|
+
const targets = [];
|
|
271
|
+
|
|
272
|
+
for (const match of content.matchAll(/\bgetByTestId\(\s*["'`]([^"'`]+)["'`]\s*\)/gu)) {
|
|
273
|
+
targets.push({
|
|
274
|
+
kind: "testId",
|
|
275
|
+
value: match[1],
|
|
276
|
+
confidence: "high",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const match of content.matchAll(/\blocator\(\s*["'`]\[data-testid=(?:"|')([^"'`\]]+)(?:"|')\]["'`]\s*\)/gu)) {
|
|
281
|
+
targets.push({
|
|
282
|
+
kind: "testId",
|
|
283
|
+
value: match[1],
|
|
284
|
+
confidence: "medium",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return dedupeTargets(targets);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function discoverPageViews(serviceName, serviceRoot, nextAppRoot, exclude = []) {
|
|
292
|
+
const pageFiles = walkFiles(nextAppRoot, { baseDir: serviceRoot, exclude }).filter(
|
|
293
|
+
(filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts")
|
|
294
|
+
);
|
|
266
295
|
const nodes = [];
|
|
267
296
|
const edges = [];
|
|
268
297
|
const pageEntries = [];
|
|
@@ -300,8 +329,8 @@ function discoverPageViews(serviceName, serviceRoot, nextAppRoot) {
|
|
|
300
329
|
return { nodes, edges, pageEntries };
|
|
301
330
|
}
|
|
302
331
|
|
|
303
|
-
function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot) {
|
|
304
|
-
const routeFiles = walkFiles(path.join(nextAppRoot, "api")).filter(
|
|
332
|
+
function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot, exclude = []) {
|
|
333
|
+
const routeFiles = walkFiles(path.join(nextAppRoot, "api"), { baseDir: serviceRoot, exclude }).filter(
|
|
305
334
|
(filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")
|
|
306
335
|
);
|
|
307
336
|
const nodes = [];
|
|
@@ -348,7 +377,7 @@ function discoverApiRoutes(serviceName, serviceRoot, nextAppRoot) {
|
|
|
348
377
|
return { nodes: dedupeNodes(nodes), edges, routeEntries };
|
|
349
378
|
}
|
|
350
379
|
|
|
351
|
-
function discoverServerActions(serviceName, serviceRoot) {
|
|
380
|
+
function discoverServerActions(serviceName, serviceRoot, exclude = []) {
|
|
352
381
|
const appRoots = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")].filter((candidate) =>
|
|
353
382
|
fs.existsSync(candidate)
|
|
354
383
|
);
|
|
@@ -357,7 +386,7 @@ function discoverServerActions(serviceName, serviceRoot) {
|
|
|
357
386
|
const actionEntries = [];
|
|
358
387
|
|
|
359
388
|
for (const appRoot of appRoots) {
|
|
360
|
-
for (const absolutePath of walkFiles(appRoot)) {
|
|
389
|
+
for (const absolutePath of walkFiles(appRoot, { baseDir: serviceRoot, exclude })) {
|
|
361
390
|
if (!absolutePath.endsWith(".ts") && !absolutePath.endsWith(".tsx")) continue;
|
|
362
391
|
const content = fs.readFileSync(absolutePath, "utf8");
|
|
363
392
|
if (!isServerActionFile(content)) continue;
|
|
@@ -630,8 +659,10 @@ function resolveServiceRoot(productDir, config) {
|
|
|
630
659
|
return path.resolve(productDir, cwd);
|
|
631
660
|
}
|
|
632
661
|
|
|
633
|
-
function walkFiles(rootDir) {
|
|
662
|
+
function walkFiles(rootDir, options = {}) {
|
|
634
663
|
if (!fs.existsSync(rootDir)) return [];
|
|
664
|
+
const baseDir = options.baseDir || rootDir;
|
|
665
|
+
const exclude = options.exclude || [];
|
|
635
666
|
const results = [];
|
|
636
667
|
const queue = [rootDir];
|
|
637
668
|
while (queue.length > 0) {
|
|
@@ -640,7 +671,8 @@ function walkFiles(rootDir) {
|
|
|
640
671
|
if (entry.isSymbolicLink()) continue;
|
|
641
672
|
const absolutePath = path.join(current, entry.name);
|
|
642
673
|
if (entry.isDirectory()) {
|
|
643
|
-
|
|
674
|
+
const relativeDirPath = normalizeDiscoveryPath(path.relative(baseDir, absolutePath));
|
|
675
|
+
if (shouldExcludeDiscoveryPath(relativeDirPath, exclude)) continue;
|
|
644
676
|
queue.push(absolutePath);
|
|
645
677
|
continue;
|
|
646
678
|
}
|
|
@@ -763,6 +795,16 @@ function dedupeNodes(nodes) {
|
|
|
763
795
|
});
|
|
764
796
|
}
|
|
765
797
|
|
|
798
|
+
function dedupeTargets(targets) {
|
|
799
|
+
const seen = new Set();
|
|
800
|
+
return targets.filter((target) => {
|
|
801
|
+
const key = `${target.kind}:${target.value}`;
|
|
802
|
+
if (seen.has(key)) return false;
|
|
803
|
+
seen.add(key);
|
|
804
|
+
return true;
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
766
808
|
function dedupeBackendImports(entries) {
|
|
767
809
|
const seen = new Set();
|
|
768
810
|
return entries.filter((entry) => {
|
|
@@ -153,6 +153,18 @@ describe("coverage graph builder", () => {
|
|
|
153
153
|
const productDir = createProduct();
|
|
154
154
|
writeFile(productDir, "src/app/campaigns/page.tsx", `export default function CampaignsPage() { return null; }`);
|
|
155
155
|
writeFile(productDir, "src/app/api/campaigns/route.ts", `export async function GET() { return Response.json({ ok: true }); }`);
|
|
156
|
+
writeFile(
|
|
157
|
+
productDir,
|
|
158
|
+
"src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
|
|
159
|
+
`
|
|
160
|
+
import { expect, test } from "@playwright/test";
|
|
161
|
+
|
|
162
|
+
test("campaigns route", async ({ page }) => {
|
|
163
|
+
await page.goto("/campaigns");
|
|
164
|
+
await expect(page.getByTestId("campaign-save-button")).toBeVisible();
|
|
165
|
+
});
|
|
166
|
+
`
|
|
167
|
+
);
|
|
156
168
|
|
|
157
169
|
const graph = buildCoverageGraph({
|
|
158
170
|
productDir,
|
|
@@ -184,6 +196,15 @@ describe("coverage graph builder", () => {
|
|
|
184
196
|
expect.objectContaining({
|
|
185
197
|
testFilePath: "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
|
|
186
198
|
coveredNodeIds: ["page_view:web:/campaigns"],
|
|
199
|
+
details: expect.objectContaining({
|
|
200
|
+
route: "/campaigns",
|
|
201
|
+
targets: [
|
|
202
|
+
expect.objectContaining({
|
|
203
|
+
kind: "testId",
|
|
204
|
+
value: "campaign-save-button",
|
|
205
|
+
}),
|
|
206
|
+
],
|
|
207
|
+
}),
|
|
187
208
|
}),
|
|
188
209
|
expect.objectContaining({
|
|
189
210
|
testFilePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
|
|
@@ -205,6 +226,27 @@ describe("coverage graph builder", () => {
|
|
|
205
226
|
expect(context.pageByRoute.get("/projects/[projectId]").node).toBeTruthy();
|
|
206
227
|
expect(context.apiRouteByKey.get("GET:/api/projects/[projectId]").node).toBeTruthy();
|
|
207
228
|
});
|
|
229
|
+
|
|
230
|
+
it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
|
|
231
|
+
const productDir = createProduct();
|
|
232
|
+
writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { return null; }`);
|
|
233
|
+
writeFile(productDir, "coverage/page.tsx", `export default function OutputPage() { return null; }`);
|
|
234
|
+
|
|
235
|
+
const context = buildServiceCoverageContext(
|
|
236
|
+
productDir,
|
|
237
|
+
"web",
|
|
238
|
+
{
|
|
239
|
+
local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
|
|
240
|
+
discovery: { roots: ["app"] },
|
|
241
|
+
},
|
|
242
|
+
{ exclude: ["coverage"] }
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(context.pageByRoute.get("/coverage").node).toMatchObject({
|
|
246
|
+
filePath: "app/coverage/page.tsx",
|
|
247
|
+
});
|
|
248
|
+
expect(context.graph.nodes.some((node) => node.filePath === "coverage/page.tsx")).toBe(false);
|
|
249
|
+
});
|
|
208
250
|
});
|
|
209
251
|
|
|
210
252
|
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.60",
|
|
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.60"
|
|
15
15
|
},
|
|
16
16
|
"private": false
|
|
17
17
|
}
|
|
@@ -277,7 +277,7 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
277
277
|
label: pageNode.label,
|
|
278
278
|
service: pageNode.service,
|
|
279
279
|
route: pageNode.route || null,
|
|
280
|
-
targets:
|
|
280
|
+
targets: collectTargetsForEvidence(relevantEvidence, pageNode.target),
|
|
281
281
|
supportingTests,
|
|
282
282
|
viaNodes,
|
|
283
283
|
confidence: supportingTests.some((entry) => entry.type === "pw") ? "high" : "medium",
|
|
@@ -298,7 +298,7 @@ function buildGraphProjection(context, page, matchedServiceName) {
|
|
|
298
298
|
label: supporting?.label || pageNode.label,
|
|
299
299
|
service: pageNode.service,
|
|
300
300
|
route: pageNode.route || null,
|
|
301
|
-
targets:
|
|
301
|
+
targets: collectTargetsForEvidence([entry], pageNode.target),
|
|
302
302
|
failedTests: supporting ? [{ ...supporting, error: failed?.error || supporting.error || null, status: "failed" }] : [],
|
|
303
303
|
viaNodes: collectViaNodes([entry], reachableNodeIds, nodeById, pageNode.id),
|
|
304
304
|
};
|
|
@@ -351,6 +351,23 @@ function collectViaNodes(evidenceEntries, reachableNodeIds, nodeById, pageNodeId
|
|
|
351
351
|
return [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
function collectTargetsForEvidence(evidenceEntries, pageTarget) {
|
|
355
|
+
const targets = [];
|
|
356
|
+
if (pageTarget) targets.push(pageTarget);
|
|
357
|
+
for (const entry of evidenceEntries || []) {
|
|
358
|
+
for (const target of entry?.details?.targets || []) {
|
|
359
|
+
targets.push(target);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const seen = new Set();
|
|
363
|
+
return targets.filter((target) => {
|
|
364
|
+
const key = `${target.kind}:${target.value}`;
|
|
365
|
+
if (seen.has(key)) return false;
|
|
366
|
+
seen.add(key);
|
|
367
|
+
return true;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
354
371
|
function buildSupportingTestRef(evidence, discoveryByFile, runArtifact) {
|
|
355
372
|
const discovery = discoveryByFile.get(evidence.testFilePath);
|
|
356
373
|
const runFile = findRunFileResult(runArtifact, evidence.testFilePath);
|
|
@@ -90,7 +90,10 @@ const context = {
|
|
|
90
90
|
framework: "playwright",
|
|
91
91
|
testFilePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
|
|
92
92
|
coveredNodeIds: ["page_view:web:/coverage"],
|
|
93
|
-
details: {
|
|
93
|
+
details: {
|
|
94
|
+
route: "/coverage",
|
|
95
|
+
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
96
|
+
},
|
|
94
97
|
},
|
|
95
98
|
{
|
|
96
99
|
id: "evidence:web:app/api/coverage/__testkit__/coverage.int.testkit.ts",
|
|
@@ -157,6 +160,7 @@ describe("testkit bridge", () => {
|
|
|
157
160
|
failures: [
|
|
158
161
|
{
|
|
159
162
|
label: "Coverage",
|
|
163
|
+
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
160
164
|
failedTests: [
|
|
161
165
|
{
|
|
162
166
|
filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
|
|
@@ -168,6 +172,7 @@ describe("testkit bridge", () => {
|
|
|
168
172
|
coverage: [
|
|
169
173
|
{
|
|
170
174
|
label: "Coverage",
|
|
175
|
+
targets: [{ kind: "testId", value: "coverage-refresh-button", confidence: "high" }],
|
|
171
176
|
supportingTests: [
|
|
172
177
|
{
|
|
173
178
|
filePath: "app/coverage/__testkit__/coverage.pw.testkit.ts",
|
|
@@ -208,10 +208,12 @@ function normalizeEvidenceDetails(value) {
|
|
|
208
208
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
209
209
|
const requestPaths = normalizeStringArray(value.requestPaths);
|
|
210
210
|
const route = normalizeOptionalString(value.route);
|
|
211
|
-
|
|
211
|
+
const targets = Array.isArray(value.targets) ? value.targets.map(normalizeBrowserTarget).filter(Boolean) : [];
|
|
212
|
+
if (requestPaths.length === 0 && !route && targets.length === 0) return null;
|
|
212
213
|
return {
|
|
213
214
|
...(requestPaths.length > 0 ? { requestPaths } : {}),
|
|
214
215
|
...(route ? { route } : {}),
|
|
216
|
+
...(targets.length > 0 ? { targets } : {}),
|
|
215
217
|
};
|
|
216
218
|
}
|
|
217
219
|
|
|
@@ -74,6 +74,13 @@ describe("testkit browser protocol", () => {
|
|
|
74
74
|
details: {
|
|
75
75
|
route: "/coverage",
|
|
76
76
|
requestPaths: ["/api/coverage"],
|
|
77
|
+
targets: [
|
|
78
|
+
{
|
|
79
|
+
kind: "testId",
|
|
80
|
+
value: "coverage-refresh-button",
|
|
81
|
+
confidence: "high",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
77
84
|
},
|
|
78
85
|
})
|
|
79
86
|
).toEqual({
|
|
@@ -89,6 +96,13 @@ describe("testkit browser protocol", () => {
|
|
|
89
96
|
details: {
|
|
90
97
|
route: "/coverage",
|
|
91
98
|
requestPaths: ["/api/coverage"],
|
|
99
|
+
targets: [
|
|
100
|
+
{
|
|
101
|
+
kind: "testId",
|
|
102
|
+
value: "coverage-refresh-button",
|
|
103
|
+
confidence: "high",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
92
106
|
},
|
|
93
107
|
});
|
|
94
108
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
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.60",
|
|
63
|
+
"@elench/testkit-protocol": "0.1.60",
|
|
64
64
|
"@babel/code-frame": "^7.29.0",
|
|
65
65
|
"@oclif/core": "^4.10.6",
|
|
66
66
|
"esbuild": "^0.25.11",
|