@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.
@@ -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 { suiteFiles, legacyFiles } = discoverFiles(productDir);
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 queue = [productDir];
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
- if (IGNORED_DIRS.has(entry.name)) continue;
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 owningRules = serviceRules.filter((rule) => ownsFile(rule, filePath));
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 discoveryRoots = Array.isArray(serviceConfig?.discovery?.roots)
297
- ? serviceConfig.discovery.roots
298
- : [cwd];
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: normalizedRoot === "." ? null : normalizedRoot,
307
- depth: normalizedRoot === "." ? 0 : normalizedRoot.split("/").filter(Boolean).length,
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") {
@@ -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, opts.discoveryOptions || {});
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
 
@@ -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
- const IGNORED_DIRS = new Set([
6
- ".cache",
7
- ".git",
8
- ".hg",
9
- ".next",
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 discoverPageViews(serviceName, serviceRoot, nextAppRoot) {
265
- const pageFiles = walkFiles(nextAppRoot).filter((filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts"));
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
- if (IGNORED_DIRS.has(entry.name)) continue;
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() {
@@ -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
+ });
@@ -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.58",
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.58"
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: pageNode.target ? [pageNode.target] : [],
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: pageNode.target ? [pageNode.target] : [],
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: { route: "/coverage" },
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -82,6 +82,7 @@ export interface CoverageEvidence {
82
82
  details?: {
83
83
  requestPaths?: string[];
84
84
  route?: string;
85
+ targets?: BrowserTarget[];
85
86
  };
86
87
  }
87
88
 
@@ -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
- if (requestPaths.length === 0 && !route) return null;
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.58",
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.58",
63
- "@elench/testkit-protocol": "0.1.58",
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",