@elench/testkit 0.1.57 → 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.
@@ -0,0 +1,112 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { startBrowserBridgeServer } from "@elench/testkit-bridge";
3
+ import { loadConfigContext, resolveProductDir } from "../../../config/index.mjs";
4
+ import { discoverTests } from "../../../discovery/index.mjs";
5
+ import { loadCurrentRunArtifact } from "../../viewer.mjs";
6
+
7
+ export default class BrowserServeCommand extends Command {
8
+ static summary = "Serve the local browser bridge for the current testkit product";
9
+
10
+ static enableJsonFlag = true;
11
+
12
+ static flags = {
13
+ dir: Flags.string({
14
+ description: "Product directory",
15
+ }),
16
+ host: Flags.string({
17
+ description: "Host to bind the browser bridge server",
18
+ default: "127.0.0.1",
19
+ }),
20
+ port: Flags.integer({
21
+ description: "Port to bind the browser bridge server",
22
+ default: 3847,
23
+ }),
24
+ };
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(BrowserServeCommand);
28
+ const productDir = resolveProductDir(process.cwd(), flags.dir);
29
+
30
+ const adapter = {
31
+ loadProductContext: async () => {
32
+ const [configContext, discovery] = await Promise.all([
33
+ loadConfigContext({
34
+ dir: productDir,
35
+ discoveryOptions: { strict: false },
36
+ }),
37
+ discoverTests({
38
+ dir: productDir,
39
+ diagnostics: "report",
40
+ }),
41
+ ]);
42
+
43
+ return {
44
+ product: {
45
+ name: discovery.product.name,
46
+ directory: discovery.product.directory,
47
+ },
48
+ services: configContext.configs
49
+ .map((config) => {
50
+ const baseUrl = config.testkit.local?.baseUrl || null;
51
+ const browserOrigins = config.testkit.browser?.origins || [];
52
+ if (!baseUrl && browserOrigins.length === 0) return null;
53
+ const serviceEntries = [];
54
+ if (baseUrl && !baseUrl.includes("{port}")) {
55
+ try {
56
+ const parsed = new URL(baseUrl);
57
+ serviceEntries.push({
58
+ name: config.name,
59
+ baseUrl,
60
+ origin: parsed.origin,
61
+ });
62
+ } catch {
63
+ // Ignore invalid local.baseUrl templates here; explicit browser origins still work.
64
+ }
65
+ }
66
+ for (const origin of browserOrigins) {
67
+ serviceEntries.push({
68
+ name: config.name,
69
+ baseUrl: origin,
70
+ origin,
71
+ });
72
+ }
73
+ return serviceEntries;
74
+ })
75
+ .flat()
76
+ .filter(Boolean),
77
+ discovery,
78
+ runArtifact: loadRunArtifactIfPresent(productDir),
79
+ };
80
+ },
81
+ };
82
+
83
+ const serverRef = await startBrowserBridgeServer(adapter, {
84
+ host: flags.host,
85
+ port: flags.port,
86
+ });
87
+
88
+ const payload = {
89
+ ok: true,
90
+ productDir,
91
+ host: serverRef.host,
92
+ port: serverRef.port,
93
+ url: serverRef.url,
94
+ };
95
+
96
+ if (!this.jsonEnabled()) {
97
+ this.log(`testkit browser bridge serving ${productDir}`);
98
+ this.log(`Listening on ${serverRef.url}`);
99
+ }
100
+
101
+ await new Promise(() => {});
102
+ return payload;
103
+ }
104
+ }
105
+
106
+ function loadRunArtifactIfPresent(productDir) {
107
+ try {
108
+ return loadCurrentRunArtifact(productDir);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
@@ -9,6 +9,7 @@ export function normalizeCliArgs(argv) {
9
9
  "artifacts",
10
10
  "watch",
11
11
  "discover",
12
+ "browser",
12
13
  "known-failures",
13
14
  "db",
14
15
  "help",
@@ -77,6 +78,9 @@ function reorderCommandArgs(args, positionals) {
77
78
  if (positionals[0]?.value === "db" && positionals[1] && positionals[2]) {
78
79
  commandTokens.push(positionals[1], positionals[2]);
79
80
  }
81
+ if (positionals[0]?.value === "browser" && positionals[1]) {
82
+ commandTokens.push(positionals[1]);
83
+ }
80
84
  const commandIndexes = new Set(commandTokens.map((token) => token.index));
81
85
  return [
82
86
  ...commandTokens.map((token) => token.value),
@@ -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);
@@ -204,10 +204,73 @@ describe("filesystem-discovery", () => {
204
204
  },
205
205
  ]);
206
206
  });
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
+
207
270
  });
208
271
 
209
- function writeFile(productDir, relativePath) {
272
+ function writeFile(productDir, relativePath, content = "export {};\n") {
210
273
  const absolutePath = path.join(productDir, relativePath);
211
274
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
212
- fs.writeFileSync(absolutePath, "export {};\n");
275
+ fs.writeFileSync(absolutePath, content);
213
276
  }
@@ -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,
@@ -144,6 +155,11 @@ function normalizeServiceConfig({
144
155
  ...loadServiceEnv(productDir, envFiles),
145
156
  ...(explicitService.env || {}),
146
157
  };
158
+ const browser = normalizeBrowserServiceConfig(explicitService.browser, name);
159
+ const normalizedDiscovery = mergeDiscoveryConfigs(
160
+ discovery,
161
+ normalizeServiceDiscoveryConfig(explicitService.discovery, name)
162
+ );
147
163
  if (explicitService.migrate || explicitService.seed) {
148
164
  throw new Error(
149
165
  `Service "${name}" uses removed migrate/seed hooks. Move template lifecycle to database.template.{migrate,seed,verify}.`
@@ -183,6 +199,7 @@ function normalizeServiceConfig({
183
199
  execution,
184
200
  reporting,
185
201
  dependsOn: explicitService.dependsOn || [],
202
+ discovery: normalizedDiscovery,
186
203
  database,
187
204
  databaseFrom: explicitService.databaseFrom,
188
205
  envFiles,
@@ -190,11 +207,24 @@ function normalizeServiceConfig({
190
207
  serviceEnv,
191
208
  skip,
192
209
  runtime,
210
+ browser,
193
211
  local,
194
212
  },
195
213
  };
196
214
  }
197
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
+
198
228
  function normalizeReportingConfig(value) {
199
229
  if (!value) return null;
200
230
 
@@ -702,6 +732,33 @@ function normalizeSkipReason(reason, label) {
702
732
  return normalized;
703
733
  }
704
734
 
735
+ function normalizeBrowserServiceConfig(value, serviceName) {
736
+ if (!value) return undefined;
737
+ if (typeof value !== "object" || Array.isArray(value)) {
738
+ throw new Error(`Service "${serviceName}" browser config must be an object`);
739
+ }
740
+
741
+ const origins = Array.isArray(value.origins)
742
+ ? value.origins
743
+ .map((origin) => normalizeOptionalString(origin))
744
+ .filter(Boolean)
745
+ : [];
746
+
747
+ for (const origin of origins) {
748
+ try {
749
+ const parsed = new URL(origin);
750
+ if (!parsed.origin) {
751
+ throw new Error("missing origin");
752
+ }
753
+ } catch {
754
+ throw new Error(`Service "${serviceName}" browser.origins contains an invalid URL: ${origin}`);
755
+ }
756
+ }
757
+
758
+ if (origins.length === 0) return undefined;
759
+ return { origins };
760
+ }
761
+
705
762
  function loadServiceEnv(productDir, envFiles) {
706
763
  const env = {};
707
764
  for (const envFile of envFiles) {