@elench/testkit 0.1.56 → 0.1.58

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.
@@ -29,13 +29,13 @@ const IGNORED_DIRS = new Set([
29
29
  "test-results",
30
30
  ]);
31
31
 
32
- export function discoverProject(productDir, explicitServices = {}) {
32
+ export function discoverProject(productDir, explicitServices = {}, options = {}) {
33
+ const strict = options.strict !== false;
33
34
  const { suiteFiles, legacyFiles } = discoverFiles(productDir);
34
35
  const groupedByService = {};
35
36
  const services = {};
36
- const unowned = [];
37
- const ambiguous = [];
38
- const discoveredSuites = [];
37
+ const diagnostics = buildLegacyFileDiagnostics(legacyFiles);
38
+ const discoveredFiles = [];
39
39
 
40
40
  for (const filePath of suiteFiles) {
41
41
  const rule = inferRule(filePath);
@@ -43,12 +43,20 @@ export function discoverProject(productDir, explicitServices = {}) {
43
43
 
44
44
  const owners = inferOwners(filePath, explicitServices);
45
45
  if (owners.length === 0) {
46
- unowned.push(filePath);
46
+ diagnostics.push({
47
+ code: "unowned_test",
48
+ severity: "error",
49
+ message: `Unowned test file: ${filePath}`,
50
+ path: filePath,
51
+ });
47
52
  continue;
48
53
  }
49
54
  if (owners.length > 1) {
50
- ambiguous.push({
51
- filePath,
55
+ diagnostics.push({
56
+ code: "ambiguous_test",
57
+ severity: "error",
58
+ message: `Ambiguous test file: ${filePath} -> ${owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)).join(", ")}`,
59
+ path: filePath,
52
60
  serviceNames: owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)),
53
61
  });
54
62
  continue;
@@ -58,7 +66,7 @@ export function discoverProject(productDir, explicitServices = {}) {
58
66
  services[owner.name] = mergeServiceDiscovery(services[owner.name], owner);
59
67
  const relativeToService = relativeToServiceRoot(owner, filePath);
60
68
  const suiteRef = deriveSuiteRef(relativeToService, rule.suffix);
61
- discoveredSuites.push({
69
+ discoveredFiles.push({
62
70
  serviceName: owner.name,
63
71
  type: rule.type,
64
72
  framework: rule.framework,
@@ -67,11 +75,11 @@ export function discoverProject(productDir, explicitServices = {}) {
67
75
  });
68
76
  }
69
77
 
70
- if (legacyFiles.length > 0 || unowned.length > 0 || ambiguous.length > 0) {
71
- throw buildDiscoveryError(legacyFiles, unowned, ambiguous);
78
+ if (strict && hasDiscoveryErrors(diagnostics)) {
79
+ throw buildDiscoveryErrorFromDiagnostics(diagnostics);
72
80
  }
73
81
 
74
- for (const entry of discoveredSuites) {
82
+ for (const entry of discoveredFiles) {
75
83
  const grouped = groupedByService[entry.serviceName] || {};
76
84
  const suitesForType = grouped[entry.type] || [];
77
85
  const suiteKey = entry.suitePath.join("/");
@@ -95,12 +103,23 @@ export function discoverProject(productDir, explicitServices = {}) {
95
103
  suite.files.push(entry.filePath);
96
104
  }
97
105
 
106
+ const fileEntries = [];
98
107
  for (const grouped of Object.values(groupedByService)) {
99
108
  for (const suites of Object.values(grouped)) {
100
109
  const suiteNames = disambiguateSuiteNames(suites);
101
110
  for (const suite of suites) {
102
111
  suite.name = suiteNames.get(suite._suiteKey);
103
112
  suite.files.sort((left, right) => left.localeCompare(right));
113
+ for (const filePath of suite.files) {
114
+ fileEntries.push({
115
+ serviceName: findSuiteServiceName(groupedByService, grouped, suite),
116
+ type: findSuiteType(grouped, suites),
117
+ framework: suite.framework,
118
+ suiteName: suite.name,
119
+ suitePath: [...suite._suitePath],
120
+ filePath,
121
+ });
122
+ }
104
123
  delete suite._suiteKey;
105
124
  delete suite._suitePath;
106
125
  }
@@ -111,11 +130,59 @@ export function discoverProject(productDir, explicitServices = {}) {
111
130
  return {
112
131
  services,
113
132
  suitesByService: groupedByService,
133
+ files: fileEntries.sort(
134
+ (left, right) =>
135
+ left.serviceName.localeCompare(right.serviceName) ||
136
+ left.type.localeCompare(right.type) ||
137
+ left.suiteName.localeCompare(right.suiteName) ||
138
+ left.filePath.localeCompare(right.filePath)
139
+ ),
140
+ diagnostics,
114
141
  };
115
142
  }
116
143
 
117
- export function discoverSuites(productDir, explicitServices = {}) {
118
- return discoverProject(productDir, explicitServices).suitesByService;
144
+ export function discoverSuites(productDir, explicitServices = {}, options = {}) {
145
+ return discoverProject(productDir, explicitServices, options).suitesByService;
146
+ }
147
+
148
+ export function hasDiscoveryErrors(diagnostics = []) {
149
+ return diagnostics.some((entry) => entry?.severity === "error");
150
+ }
151
+
152
+ export function buildDiscoveryErrorFromDiagnostics(diagnostics = []) {
153
+ const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
154
+
155
+ const legacyFiles = diagnostics.filter((entry) => entry.code === "legacy_path").map((entry) => entry.path);
156
+ const unownedFiles = diagnostics.filter((entry) => entry.code === "unowned_test").map((entry) => entry.path);
157
+ const ambiguousFiles = diagnostics.filter((entry) => entry.code === "ambiguous_test");
158
+
159
+ if (legacyFiles.length > 0) {
160
+ lines.push("");
161
+ lines.push("Legacy test files outside __testkit__:");
162
+ for (const filePath of legacyFiles) {
163
+ lines.push(`- ${filePath}`);
164
+ }
165
+ }
166
+
167
+ if (unownedFiles.length > 0) {
168
+ lines.push("");
169
+ lines.push("Unowned test files:");
170
+ for (const filePath of unownedFiles) {
171
+ lines.push(`- ${filePath}`);
172
+ }
173
+ }
174
+
175
+ if (ambiguousFiles.length > 0) {
176
+ lines.push("");
177
+ lines.push("Ambiguous test files:");
178
+ for (const entry of ambiguousFiles) {
179
+ lines.push(`- ${entry.path} -> ${entry.serviceNames.join(", ")}`);
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
185
+ return new Error(lines.join("\n"));
119
186
  }
120
187
 
121
188
  function discoverFiles(productDir) {
@@ -306,38 +373,6 @@ function mergeServiceDiscovery(existing, owner) {
306
373
  };
307
374
  }
308
375
 
309
- function buildDiscoveryError(legacyFiles, unowned, ambiguous) {
310
- const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
311
-
312
- if (legacyFiles.length > 0) {
313
- lines.push("");
314
- lines.push("Legacy test files outside __testkit__:");
315
- for (const filePath of legacyFiles) {
316
- lines.push(`- ${filePath}`);
317
- }
318
- }
319
-
320
- if (unowned.length > 0) {
321
- lines.push("");
322
- lines.push("Unowned test files:");
323
- for (const filePath of unowned) {
324
- lines.push(`- ${filePath}`);
325
- }
326
- }
327
-
328
- if (ambiguous.length > 0) {
329
- lines.push("");
330
- lines.push("Ambiguous test files:");
331
- for (const entry of ambiguous) {
332
- lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
333
- }
334
- }
335
-
336
- lines.push("");
337
- lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
338
- return new Error(lines.join("\n"));
339
- }
340
-
341
376
  function inferRule(filePath) {
342
377
  return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
343
378
  }
@@ -351,3 +386,29 @@ function normalizePath(value) {
351
386
  if (normalized === "." || normalized === "./") return ".";
352
387
  return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
353
388
  }
389
+
390
+ function buildLegacyFileDiagnostics(legacyFiles) {
391
+ return legacyFiles.map((filePath) => ({
392
+ code: "legacy_path",
393
+ severity: "error",
394
+ message: `Legacy test file outside __testkit__: ${filePath}`,
395
+ path: filePath,
396
+ }));
397
+ }
398
+
399
+ function findSuiteServiceName(groupedByService, grouped, suite) {
400
+ for (const [serviceName, candidate] of Object.entries(groupedByService)) {
401
+ if (candidate !== grouped) continue;
402
+ for (const suites of Object.values(candidate)) {
403
+ if (suites.includes(suite)) return serviceName;
404
+ }
405
+ }
406
+ return "app";
407
+ }
408
+
409
+ function findSuiteType(grouped, targetSuites) {
410
+ for (const [type, suites] of Object.entries(grouped)) {
411
+ if (suites === targetSuites) return type;
412
+ }
413
+ return "integration";
414
+ }
@@ -22,7 +22,7 @@ describe("filesystem-discovery", () => {
22
22
  writeFile(productDir, "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts");
23
23
  writeFile(productDir, "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts");
24
24
 
25
- const suites = discoverSuites(productDir, {
25
+ const project = discoverProject(productDir, {
26
26
  api: {
27
27
  local: {
28
28
  cwd: ".",
@@ -35,6 +35,7 @@ describe("filesystem-discovery", () => {
35
35
  },
36
36
  });
37
37
 
38
+ const suites = project.suitesByService;
38
39
  expect(suites.api.integration).toEqual([
39
40
  {
40
41
  name: "auth",
@@ -61,6 +62,41 @@ describe("filesystem-discovery", () => {
61
62
  framework: "playwright",
62
63
  },
63
64
  ]);
65
+ expect(project.files).toEqual([
66
+ {
67
+ serviceName: "api",
68
+ type: "integration",
69
+ framework: "k6",
70
+ suiteName: "auth",
71
+ suitePath: ["src", "api", "routes", "auth"],
72
+ filePath: "src/api/routes/__testkit__/auth/me.int.testkit.ts",
73
+ },
74
+ {
75
+ serviceName: "api",
76
+ type: "integration",
77
+ framework: "k6",
78
+ suiteName: "health",
79
+ suitePath: ["src", "api", "routes", "health"],
80
+ filePath: "src/api/routes/__testkit__/health/ready.int.testkit.ts",
81
+ },
82
+ {
83
+ serviceName: "api",
84
+ type: "scenario",
85
+ framework: "k6",
86
+ suiteName: "journeys",
87
+ suitePath: ["src", "api", "routes", "journeys"],
88
+ filePath: "src/api/routes/__testkit__/journeys/smoke.scenario.testkit.ts",
89
+ },
90
+ {
91
+ serviceName: "frontend",
92
+ type: "e2e",
93
+ framework: "playwright",
94
+ suiteName: "homepage",
95
+ suitePath: ["app", "homepage"],
96
+ filePath: "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts",
97
+ },
98
+ ]);
99
+ expect(project.diagnostics).toEqual([]);
64
100
  });
65
101
 
66
102
  it("infers the suite from the directory that owns __testkit__", () => {
@@ -130,10 +166,49 @@ describe("filesystem-discovery", () => {
130
166
  })
131
167
  ).toThrow("Legacy test files outside __testkit__");
132
168
  });
169
+
170
+ it("reports legacy files in non-strict mode", () => {
171
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
172
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
173
+
174
+ writeFile(productDir, "src/api/routes/__testkit__/health/ready.int.testkit.ts");
175
+ writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
176
+
177
+ const project = discoverProject(
178
+ productDir,
179
+ {
180
+ api: {
181
+ local: {
182
+ cwd: ".",
183
+ },
184
+ },
185
+ },
186
+ {
187
+ strict: false,
188
+ }
189
+ );
190
+
191
+ expect(project.suitesByService.api.integration).toEqual([
192
+ {
193
+ name: "health",
194
+ files: ["src/api/routes/__testkit__/health/ready.int.testkit.ts"],
195
+ framework: "k6",
196
+ },
197
+ ]);
198
+ expect(project.diagnostics).toEqual([
199
+ {
200
+ code: "legacy_path",
201
+ severity: "error",
202
+ message: "Legacy test file outside __testkit__: tests/api/integration/health.int.testkit.ts",
203
+ path: "tests/api/integration/health.int.testkit.ts",
204
+ },
205
+ ]);
206
+ });
207
+
133
208
  });
134
209
 
135
- function writeFile(productDir, relativePath) {
210
+ function writeFile(productDir, relativePath, content = "export {};\n") {
136
211
  const absolutePath = path.join(productDir, relativePath);
137
212
  fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
138
- fs.writeFileSync(absolutePath, "export {};\n");
213
+ fs.writeFileSync(absolutePath, content);
139
214
  }
@@ -31,14 +31,15 @@ export function parseDotenv(filePath) {
31
31
  return parseDotenvString(fs.readFileSync(filePath, "utf8"));
32
32
  }
33
33
 
34
- export async function loadConfigs(opts = {}) {
34
+ export async function loadConfigContext(opts = {}) {
35
35
  const productDir = resolveProductDir(process.cwd(), opts.dir);
36
- const { setup, setupFile } = await loadTestkitSetup(productDir);
36
+ const setupContext = opts.setupContext || (await loadTestkitSetup(productDir));
37
+ const { setup, setupFile } = setupContext;
37
38
  const execution = normalizeRepoExecution(setup.execution);
38
39
  const reporting = normalizeReportingConfig(setup.reporting);
39
40
  const toolchains = normalizeToolchainRegistry(setup.toolchains);
40
41
  const explicitServices = setup.services || {};
41
- const discovery = discoverProject(productDir, explicitServices);
42
+ const discovery = discoverProject(productDir, explicitServices, opts.discoveryOptions || {});
42
43
  const serviceNames = new Set([
43
44
  ...Object.keys(explicitServices),
44
45
  ...Object.keys(discovery.suitesByService),
@@ -63,6 +64,23 @@ export async function loadConfigs(opts = {}) {
63
64
 
64
65
  validateConfigCoverage(configs);
65
66
 
67
+ return {
68
+ productDir,
69
+ setup,
70
+ setupFile,
71
+ execution,
72
+ reporting,
73
+ toolchains,
74
+ explicitServices,
75
+ discovery,
76
+ configs,
77
+ };
78
+ }
79
+
80
+ export async function loadConfigs(opts = {}) {
81
+ const context = await loadConfigContext(opts);
82
+ const { configs } = context;
83
+
66
84
  const filtered = opts.service
67
85
  ? configs.filter((config) => config.name === opts.service)
68
86
  : configs;
@@ -126,6 +144,7 @@ function normalizeServiceConfig({
126
144
  ...loadServiceEnv(productDir, envFiles),
127
145
  ...(explicitService.env || {}),
128
146
  };
147
+ const browser = normalizeBrowserServiceConfig(explicitService.browser, name);
129
148
  if (explicitService.migrate || explicitService.seed) {
130
149
  throw new Error(
131
150
  `Service "${name}" uses removed migrate/seed hooks. Move template lifecycle to database.template.{migrate,seed,verify}.`
@@ -172,6 +191,7 @@ function normalizeServiceConfig({
172
191
  serviceEnv,
173
192
  skip,
174
193
  runtime,
194
+ browser,
175
195
  local,
176
196
  },
177
197
  };
@@ -684,6 +704,33 @@ function normalizeSkipReason(reason, label) {
684
704
  return normalized;
685
705
  }
686
706
 
707
+ function normalizeBrowserServiceConfig(value, serviceName) {
708
+ if (!value) return undefined;
709
+ if (typeof value !== "object" || Array.isArray(value)) {
710
+ throw new Error(`Service "${serviceName}" browser config must be an object`);
711
+ }
712
+
713
+ const origins = Array.isArray(value.origins)
714
+ ? value.origins
715
+ .map((origin) => normalizeOptionalString(origin))
716
+ .filter(Boolean)
717
+ : [];
718
+
719
+ for (const origin of origins) {
720
+ try {
721
+ const parsed = new URL(origin);
722
+ if (!parsed.origin) {
723
+ throw new Error("missing origin");
724
+ }
725
+ } catch {
726
+ throw new Error(`Service "${serviceName}" browser.origins contains an invalid URL: ${origin}`);
727
+ }
728
+ }
729
+
730
+ if (origins.length === 0) return undefined;
731
+ return { origins };
732
+ }
733
+
687
734
  function loadServiceEnv(productDir, envFiles) {
688
735
  const env = {};
689
736
  for (const envFile of envFiles) {