@elench/testkit 0.1.26 → 0.1.28

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,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
 
4
+ const TESTKIT_DIRNAME = "__testkit__";
4
5
  const DISCOVERY_RULES = [
5
6
  { suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
6
7
  { suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
@@ -27,80 +28,98 @@ const IGNORED_DIRS = new Set([
27
28
  "test-results",
28
29
  ]);
29
30
 
30
- export function discoverSuites(productDir, services) {
31
- const serviceEntries = Object.entries(services || {});
32
- const rules = serviceEntries.map(([serviceName, serviceConfig]) =>
33
- buildServiceRule(serviceName, serviceConfig)
34
- );
35
- const groupedByService = Object.fromEntries(
36
- serviceEntries.map(([serviceName]) => [serviceName, {}])
37
- );
38
- const discoveredFiles = discoverFiles(productDir);
31
+ export function discoverProject(productDir, explicitServices = {}) {
32
+ const { suiteFiles, legacyFiles } = discoverFiles(productDir);
33
+ const groupedByService = {};
34
+ const services = {};
39
35
  const unowned = [];
40
36
  const ambiguous = [];
37
+ const discoveredSuites = [];
41
38
 
42
- for (const filePath of discoveredFiles) {
39
+ for (const filePath of suiteFiles) {
43
40
  const rule = inferRule(filePath);
44
41
  if (!rule) continue;
45
42
 
46
- const matches = rules
47
- .filter((serviceRule) => ownsFile(serviceRule, filePath))
48
- .map((serviceRule) => serviceRule.name);
49
-
50
- if (matches.length === 0) {
43
+ const owners = inferOwners(filePath, explicitServices);
44
+ if (owners.length === 0) {
51
45
  unowned.push(filePath);
52
46
  continue;
53
47
  }
54
-
55
- if (matches.length > 1) {
48
+ if (owners.length > 1) {
56
49
  ambiguous.push({
57
50
  filePath,
58
- serviceNames: matches.sort((left, right) => left.localeCompare(right)),
51
+ serviceNames: owners.map((owner) => owner.name).sort((left, right) => left.localeCompare(right)),
59
52
  });
60
53
  continue;
61
54
  }
62
55
 
63
- const serviceName = matches[0];
64
- const serviceRule = rules.find((candidate) => candidate.name === serviceName);
65
- const relativeToService = relativeToServiceRoot(serviceRule, filePath);
66
- const suiteName = deriveSuiteName(relativeToService, rule.suffix);
67
- const grouped = groupedByService[serviceName];
68
- const suitesForType = grouped[rule.type] || [];
56
+ const owner = owners[0];
57
+ services[owner.name] = mergeServiceDiscovery(services[owner.name], owner);
58
+ const relativeToService = relativeToServiceRoot(owner, filePath);
59
+ const suiteRef = deriveSuiteRef(relativeToService, rule.suffix);
60
+ discoveredSuites.push({
61
+ serviceName: owner.name,
62
+ type: rule.type,
63
+ framework: rule.framework,
64
+ filePath,
65
+ suitePath: suiteRef.suitePath,
66
+ });
67
+ }
68
+
69
+ if (legacyFiles.length > 0 || unowned.length > 0 || ambiguous.length > 0) {
70
+ throw buildDiscoveryError(legacyFiles, unowned, ambiguous);
71
+ }
72
+
73
+ for (const entry of discoveredSuites) {
74
+ const grouped = groupedByService[entry.serviceName] || {};
75
+ const suitesForType = grouped[entry.type] || [];
76
+ const suiteKey = entry.suitePath.join("/");
69
77
  let suite = suitesForType.find(
70
- (candidate) => candidate.name === suiteName && candidate.framework === rule.framework
78
+ (candidate) => candidate._suiteKey === suiteKey && candidate.framework === entry.framework
71
79
  );
72
80
 
73
81
  if (!suite) {
74
82
  suite = {
75
- name: suiteName,
83
+ name: null,
76
84
  files: [],
77
- framework: rule.framework,
85
+ framework: entry.framework,
86
+ _suiteKey: suiteKey,
87
+ _suitePath: entry.suitePath,
78
88
  };
79
89
  suitesForType.push(suite);
80
- grouped[rule.type] = suitesForType;
90
+ grouped[entry.type] = suitesForType;
91
+ groupedByService[entry.serviceName] = grouped;
81
92
  }
82
93
 
83
- suite.files.push(filePath);
84
- }
85
-
86
- if (unowned.length > 0 || ambiguous.length > 0) {
87
- throw buildDiscoveryError(unowned, ambiguous);
94
+ suite.files.push(entry.filePath);
88
95
  }
89
96
 
90
97
  for (const grouped of Object.values(groupedByService)) {
91
98
  for (const suites of Object.values(grouped)) {
99
+ const suiteNames = disambiguateSuiteNames(suites);
92
100
  for (const suite of suites) {
101
+ suite.name = suiteNames.get(suite._suiteKey);
93
102
  suite.files.sort((left, right) => left.localeCompare(right));
103
+ delete suite._suiteKey;
104
+ delete suite._suitePath;
94
105
  }
95
106
  suites.sort((left, right) => left.name.localeCompare(right.name));
96
107
  }
97
108
  }
98
109
 
99
- return groupedByService;
110
+ return {
111
+ services,
112
+ suitesByService: groupedByService,
113
+ };
114
+ }
115
+
116
+ export function discoverSuites(productDir, explicitServices = {}) {
117
+ return discoverProject(productDir, explicitServices).suitesByService;
100
118
  }
101
119
 
102
120
  function discoverFiles(productDir) {
103
- const files = [];
121
+ const suiteFiles = [];
122
+ const legacyFiles = [];
104
123
  const queue = [productDir];
105
124
 
106
125
  while (queue.length > 0) {
@@ -119,48 +138,184 @@ function discoverFiles(productDir) {
119
138
  if (!entry.isFile()) continue;
120
139
 
121
140
  const relativePath = normalizePath(path.relative(productDir, absolutePath));
122
- if (inferRule(relativePath)) files.push(relativePath);
141
+ if (!inferRule(relativePath)) continue;
142
+ if (relativePath.split("/").includes(TESTKIT_DIRNAME)) {
143
+ suiteFiles.push(relativePath);
144
+ } else {
145
+ legacyFiles.push(relativePath);
146
+ }
123
147
  }
124
148
  }
125
149
 
126
- return files.sort((left, right) => left.localeCompare(right));
150
+ return {
151
+ suiteFiles: suiteFiles.sort((left, right) => left.localeCompare(right)),
152
+ legacyFiles: legacyFiles.sort((left, right) => left.localeCompare(right)),
153
+ };
154
+ }
155
+
156
+ function inferOwners(filePath, explicitServices) {
157
+ const serviceRules = Object.entries(explicitServices).flatMap(([name, config]) =>
158
+ buildServiceRules(name, config)
159
+ );
160
+ if (serviceRules.length === 0) {
161
+ return [
162
+ {
163
+ name: "app",
164
+ source: "implicit-root",
165
+ cwdPrefix: ".",
166
+ },
167
+ ];
168
+ }
169
+
170
+ const owningRules = serviceRules.filter((rule) => ownsFile(rule, filePath));
171
+ if (owningRules.length === 0) {
172
+ return [];
173
+ }
174
+
175
+ const maxDepth = Math.max(...owningRules.map((rule) => rule.depth));
176
+ return owningRules.filter((rule) => rule.depth === maxDepth);
127
177
  }
128
178
 
129
- function buildServiceRule(serviceName, serviceConfig) {
130
- const testPrefix = normalizePath(path.posix.join("tests", serviceName));
179
+ function disambiguateSuiteNames(suites) {
180
+ const used = new Map();
181
+ const resolved = new Map();
182
+
183
+ for (const suite of suites) {
184
+ const pathSegments = suite._suitePath;
185
+ let candidate = "";
186
+
187
+ for (let depth = 1; depth <= pathSegments.length; depth += 1) {
188
+ candidate = pathSegments.slice(-depth).join("-");
189
+ const existing = used.get(candidate);
190
+ if (!existing) {
191
+ used.set(candidate, suite._suiteKey);
192
+ break;
193
+ }
194
+ if (existing === suite._suiteKey) break;
195
+ candidate = "";
196
+ }
197
+
198
+ if (!candidate) {
199
+ candidate = pathSegments.join("-");
200
+ }
201
+
202
+ resolved.set(suite._suiteKey, candidate);
203
+ }
204
+
205
+ if (resolved.size !== suites.length) {
206
+ for (const suite of suites) {
207
+ if (!resolved.has(suite._suiteKey)) {
208
+ resolved.set(suite._suiteKey, suite._suitePath.join("-"));
209
+ }
210
+ }
211
+ }
212
+
213
+ if (new Set(resolved.values()).size !== suites.length) {
214
+ const counts = new Map();
215
+ for (const suite of suites) {
216
+ const base = suite._suitePath.join("-");
217
+ const count = counts.get(base) || 0;
218
+ counts.set(base, count + 1);
219
+ resolved.set(suite._suiteKey, count === 0 ? base : `${base}-${count + 1}`);
220
+ }
221
+ }
222
+
223
+ return resolved;
224
+ }
225
+
226
+ function buildServiceRules(serviceName, serviceConfig) {
131
227
  const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
132
- return {
133
- name: serviceName,
134
- testPrefix,
135
- cwdPrefix: cwd === "." ? null : cwd,
136
- };
228
+ const discoveryRoots = Array.isArray(serviceConfig?.discovery?.roots)
229
+ ? serviceConfig.discovery.roots
230
+ : [cwd];
231
+
232
+ return discoveryRoots.map((root) => {
233
+ const normalizedRoot = normalizePath(root || ".");
234
+ return {
235
+ name: serviceName,
236
+ source: "cwd",
237
+ cwdPrefix: cwd === "." ? null : cwd,
238
+ matchPrefix: normalizedRoot === "." ? null : normalizedRoot,
239
+ depth: normalizedRoot === "." ? 0 : normalizedRoot.split("/").filter(Boolean).length,
240
+ };
241
+ });
137
242
  }
138
243
 
139
244
  function ownsFile(serviceRule, filePath) {
140
- if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
141
- if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
245
+ if (serviceRule.depth === 0) return true;
246
+ if (serviceRule.matchPrefix && hasPrefix(filePath, serviceRule.matchPrefix)) return true;
142
247
  return false;
143
248
  }
144
249
 
145
250
  function relativeToServiceRoot(serviceRule, filePath) {
146
- if (hasPrefix(filePath, serviceRule.testPrefix)) {
147
- return path.posix.relative(serviceRule.testPrefix, filePath);
148
- }
149
251
  if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
150
252
  return path.posix.relative(serviceRule.cwdPrefix, filePath);
151
253
  }
152
254
  return filePath;
153
255
  }
154
256
 
155
- function deriveSuiteName(relativePath, suffix) {
257
+ function deriveSuiteRef(relativePath, suffix) {
156
258
  const parts = relativePath.split("/").filter(Boolean);
157
- if (parts.length >= 3) return parts[1];
158
- return path.posix.basename(relativePath, suffix);
259
+ const testkitIndex = parts.indexOf(TESTKIT_DIRNAME);
260
+ if (testkitIndex === -1) {
261
+ const fallback = path.posix.basename(relativePath, suffix);
262
+ return {
263
+ suitePath: [fallback],
264
+ };
265
+ }
266
+
267
+ const before = parts.slice(0, testkitIndex);
268
+ const after = parts.slice(testkitIndex + 1);
269
+ if (after.length === 0) {
270
+ const fallback = path.posix.basename(relativePath, suffix);
271
+ return {
272
+ suitePath: [fallback],
273
+ };
274
+ }
275
+
276
+ if (after.length === 1) {
277
+ if (before.length === 0) {
278
+ return {
279
+ suitePath: [path.posix.basename(after[0], suffix)],
280
+ };
281
+ }
282
+ return {
283
+ suitePath: before,
284
+ };
285
+ }
286
+
287
+ return {
288
+ suitePath: [...before, after[0]],
289
+ };
290
+ }
291
+
292
+ function mergeServiceDiscovery(existing, owner) {
293
+ if (!existing) {
294
+ return {
295
+ name: owner.name,
296
+ inferredLocalCwd: owner.cwdPrefix || ".",
297
+ source: owner.source,
298
+ };
299
+ }
300
+
301
+ return {
302
+ ...existing,
303
+ inferredLocalCwd:
304
+ existing.inferredLocalCwd === "." ? owner.cwdPrefix || "." : existing.inferredLocalCwd,
305
+ };
159
306
  }
160
307
 
161
- function buildDiscoveryError(unowned, ambiguous) {
308
+ function buildDiscoveryError(legacyFiles, unowned, ambiguous) {
162
309
  const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
163
310
 
311
+ if (legacyFiles.length > 0) {
312
+ lines.push("");
313
+ lines.push("Legacy test files outside __testkit__:");
314
+ for (const filePath of legacyFiles) {
315
+ lines.push(`- ${filePath}`);
316
+ }
317
+ }
318
+
164
319
  if (unowned.length > 0) {
165
320
  lines.push("");
166
321
  lines.push("Unowned test files:");
@@ -178,7 +333,7 @@ function buildDiscoveryError(unowned, ambiguous) {
178
333
  }
179
334
 
180
335
  lines.push("");
181
- lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
336
+ lines.push('Expected test files to live under a "__testkit__" directory within a service root.');
182
337
  return new Error(lines.join("\n"));
183
338
  }
184
339
 
@@ -2,7 +2,7 @@ import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
5
- import { discoverSuites } from "./discovery.mjs";
5
+ import { discoverProject, discoverSuites } from "./discovery.mjs";
6
6
 
7
7
  const cleanups = [];
8
8
 
@@ -13,13 +13,13 @@ afterEach(() => {
13
13
  });
14
14
 
15
15
  describe("filesystem-discovery", () => {
16
- it("discovers tests by convention without config registration", () => {
16
+ it("discovers colocated __testkit__ suites by service root", () => {
17
17
  const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
18
18
  cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
19
19
 
20
- writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
21
- writeFile(productDir, "tests/api/integration/auth/me.int.testkit.ts");
22
- writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
20
+ writeFile(productDir, "src/api/routes/__testkit__/auth/me.int.testkit.ts");
21
+ writeFile(productDir, "src/api/routes/__testkit__/health/ready.int.testkit.ts");
22
+ writeFile(productDir, "frontend/app/__testkit__/homepage/homepage.pw.testkit.ts");
23
23
 
24
24
  const suites = discoverSuites(productDir, {
25
25
  api: {
@@ -37,61 +37,90 @@ describe("filesystem-discovery", () => {
37
37
  expect(suites.api.integration).toEqual([
38
38
  {
39
39
  name: "auth",
40
- files: ["tests/api/integration/auth/me.int.testkit.ts"],
40
+ files: ["src/api/routes/__testkit__/auth/me.int.testkit.ts"],
41
41
  framework: "k6",
42
42
  },
43
43
  {
44
44
  name: "health",
45
- files: ["tests/api/integration/health.int.testkit.ts"],
45
+ files: ["src/api/routes/__testkit__/health/ready.int.testkit.ts"],
46
46
  framework: "k6",
47
47
  },
48
48
  ]);
49
49
  expect(suites.frontend.e2e).toEqual([
50
50
  {
51
51
  name: "homepage",
52
- files: ["frontend/e2e/homepage.pw.testkit.ts"],
52
+ files: ["frontend/app/__testkit__/homepage/homepage.pw.testkit.ts"],
53
53
  framework: "playwright",
54
54
  },
55
55
  ]);
56
56
  });
57
57
 
58
- it("fails when a discovered file does not map to any configured service", () => {
58
+ it("infers the suite from the directory that owns __testkit__", () => {
59
59
  const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
60
60
  cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
61
61
 
62
- writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
62
+ writeFile(productDir, "src/services/search/__testkit__/query/validation.int.testkit.ts");
63
63
 
64
- expect(() =>
65
- discoverSuites(productDir, {
66
- api: {
67
- local: {
68
- cwd: ".",
69
- },
64
+ const project = discoverProject(productDir, {
65
+ api: {
66
+ local: {
67
+ cwd: ".",
70
68
  },
71
- })
72
- ).toThrow("Unowned test files");
69
+ },
70
+ });
71
+ expect(project.services.api).toMatchObject({
72
+ name: "api",
73
+ inferredLocalCwd: ".",
74
+ });
75
+ expect(project.suitesByService.api.integration[0]).toMatchObject({
76
+ name: "query",
77
+ files: ["src/services/search/__testkit__/query/validation.int.testkit.ts"],
78
+ framework: "k6",
79
+ });
73
80
  });
74
81
 
75
- it("fails when a discovered file maps to multiple services", () => {
82
+ it("prefers the deepest matching service root", () => {
76
83
  const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
77
84
  cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
78
85
 
79
- writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
86
+ writeFile(productDir, "frontend/app/__testkit__/billing/lifecycle.pw.testkit.ts");
87
+
88
+ const project = discoverProject(productDir, {
89
+ app: {
90
+ local: {
91
+ cwd: ".",
92
+ },
93
+ },
94
+ frontend: {
95
+ local: {
96
+ cwd: "frontend",
97
+ },
98
+ },
99
+ });
100
+
101
+ expect(project.suitesByService.app).toBeUndefined();
102
+ expect(project.suitesByService.frontend.e2e[0]).toMatchObject({
103
+ name: "billing",
104
+ files: ["frontend/app/__testkit__/billing/lifecycle.pw.testkit.ts"],
105
+ framework: "playwright",
106
+ });
107
+ });
108
+
109
+ it("fails on legacy files outside __testkit__", () => {
110
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
111
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
112
+
113
+ writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
80
114
 
81
115
  expect(() =>
82
116
  discoverSuites(productDir, {
83
- frontend: {
84
- local: {
85
- cwd: "frontend",
86
- },
87
- },
88
- web: {
117
+ api: {
89
118
  local: {
90
- cwd: "frontend",
119
+ cwd: ".",
91
120
  },
92
121
  },
93
122
  })
94
- ).toThrow("Ambiguous test files");
123
+ ).toThrow("Legacy test files outside __testkit__");
95
124
  });
96
125
  });
97
126