@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.
@@ -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);
@@ -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).filter((filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts"));
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
- if (IGNORED_DIRS.has(entry.name)) continue;
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() {
@@ -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.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.58"
14
+ "@elench/testkit-protocol": "0.1.59"
15
15
  },
16
16
  "private": false
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.58",
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.58",
63
- "@elench/testkit-protocol": "0.1.58",
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",