@elench/testkit 0.1.21 → 0.1.22

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.
package/README.md CHANGED
@@ -104,7 +104,7 @@ npx @elench/testkit --dir my-product api int -s health
104
104
 
105
105
  1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
106
106
  `*.int.testkit.ts`, `*.e2e.testkit.ts`, `*.dal.testkit.ts`, `*.load.testkit.ts`, `*.pw.testkit.ts`
107
- 2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, database, and discovery settings
107
+ 2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
108
108
  Per-service `.env` files declared in config are loaded when present.
109
109
  3. **Database** — provisions Docker-managed local Postgres when a service declares one
110
110
  4. **Seed** — runs optional product seed commands against the provisioned database
@@ -124,7 +124,7 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
124
124
  `testkit.config.json` can also declare:
125
125
 
126
126
  - `telemetry` for optional generic HTTP result upload
127
- - `discovery.include` / `discovery.exclude` for per-service test discovery
127
+ - no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
128
128
  - `envFile` / `envFiles` for service-specific environment loading
129
129
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
130
130
  - `database.provider` for local Postgres settings
@@ -9,148 +9,189 @@ const DISCOVERY_RULES = [
9
9
  { suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
10
10
  ];
11
11
 
12
- export function discoverServiceSuites(productDir, serviceName, serviceConfig) {
13
- const include = serviceConfig.discovery?.include || [];
14
- const exclude = serviceConfig.discovery?.exclude || [];
15
- const grouped = {};
16
- const seen = new Set();
17
-
18
- for (const pattern of include) {
19
- const normalizedPattern = normalizePath(pattern);
20
- const patternInfo = compilePattern(normalizedPattern);
21
- const rootDir = path.resolve(productDir, patternInfo.baseDir);
22
- if (!fs.existsSync(rootDir)) continue;
23
-
24
- for (const relativeFile of walkFiles(productDir, rootDir)) {
25
- const normalizedFile = normalizePath(relativeFile);
26
- if (!patternInfo.regex.test(normalizedFile)) continue;
27
- if (exclude.some((candidate) => compilePattern(normalizePath(candidate)).regex.test(normalizedFile))) {
28
- continue;
29
- }
30
- if (seen.has(normalizedFile)) continue;
12
+ const IGNORED_DIRS = new Set([
13
+ ".cache",
14
+ ".git",
15
+ ".hg",
16
+ ".next",
17
+ ".nuxt",
18
+ ".playwright-browsers",
19
+ ".svn",
20
+ ".testkit",
21
+ ".turbo",
22
+ "build",
23
+ "coverage",
24
+ "dist",
25
+ "node_modules",
26
+ "playwright-report",
27
+ "test-results",
28
+ ]);
29
+
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);
39
+ const unowned = [];
40
+ const ambiguous = [];
41
+
42
+ for (const filePath of discoveredFiles) {
43
+ const rule = inferRule(filePath);
44
+ if (!rule) continue;
45
+
46
+ const matches = rules
47
+ .filter((serviceRule) => ownsFile(serviceRule, filePath))
48
+ .map((serviceRule) => serviceRule.name);
49
+
50
+ if (matches.length === 0) {
51
+ unowned.push(filePath);
52
+ continue;
53
+ }
31
54
 
32
- const rule = inferRule(normalizedFile);
33
- if (!rule) continue;
55
+ if (matches.length > 1) {
56
+ ambiguous.push({
57
+ filePath,
58
+ serviceNames: matches.sort((left, right) => left.localeCompare(right)),
59
+ });
60
+ continue;
61
+ }
34
62
 
35
- const typeSuites = grouped[rule.type] || [];
36
- typeSuites.push({
37
- name: buildSuiteName(normalizedFile, rule.suffix, patternInfo.baseDir),
38
- files: [normalizedFile],
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] || [];
69
+ let suite = suitesForType.find(
70
+ (candidate) => candidate.name === suiteName && candidate.framework === rule.framework
71
+ );
72
+
73
+ if (!suite) {
74
+ suite = {
75
+ name: suiteName,
76
+ files: [],
39
77
  framework: rule.framework,
40
- });
41
- grouped[rule.type] = typeSuites;
42
- seen.add(normalizedFile);
78
+ };
79
+ suitesForType.push(suite);
80
+ grouped[rule.type] = suitesForType;
43
81
  }
44
- }
45
82
 
46
- for (const suites of Object.values(grouped)) {
47
- suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
83
+ suite.files.push(filePath);
48
84
  }
49
85
 
50
- return grouped;
51
- }
86
+ if (unowned.length > 0 || ambiguous.length > 0) {
87
+ throw buildDiscoveryError(unowned, ambiguous);
88
+ }
52
89
 
53
- function inferRule(filePath) {
54
- return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
55
- }
90
+ for (const grouped of Object.values(groupedByService)) {
91
+ for (const suites of Object.values(grouped)) {
92
+ for (const suite of suites) {
93
+ suite.files.sort((left, right) => left.localeCompare(right));
94
+ }
95
+ suites.sort((left, right) => left.name.localeCompare(right.name));
96
+ }
97
+ }
56
98
 
57
- function buildSuiteName(filePath, suffix, baseDir) {
58
- const normalizedBase = normalizePath(baseDir);
59
- const relativeToBase =
60
- normalizedBase === "." || normalizedBase.length === 0
61
- ? filePath
62
- : path.posix.relative(normalizedBase, filePath);
63
- const candidate = relativeToBase.length > 0 ? relativeToBase : path.posix.basename(filePath);
64
- return candidate.slice(0, -suffix.length);
99
+ return groupedByService;
65
100
  }
66
101
 
67
- function walkFiles(productDir, rootDir) {
68
- const rootStats = fs.statSync(rootDir);
69
- if (rootStats.isFile()) {
70
- return [normalizePath(path.relative(productDir, rootDir))];
71
- }
72
-
102
+ function discoverFiles(productDir) {
73
103
  const files = [];
74
- const queue = [rootDir];
104
+ const queue = [productDir];
75
105
 
76
106
  while (queue.length > 0) {
77
107
  const current = queue.pop();
78
108
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
79
- const absolute = path.join(current, entry.name);
109
+ const absolutePath = path.join(current, entry.name);
110
+
111
+ if (entry.isSymbolicLink()) continue;
112
+
80
113
  if (entry.isDirectory()) {
81
- queue.push(absolute);
114
+ if (IGNORED_DIRS.has(entry.name)) continue;
115
+ queue.push(absolutePath);
82
116
  continue;
83
117
  }
118
+
84
119
  if (!entry.isFile()) continue;
85
- files.push(normalizePath(path.relative(productDir, absolute)));
120
+
121
+ const relativePath = normalizePath(path.relative(productDir, absolutePath));
122
+ if (inferRule(relativePath)) files.push(relativePath);
86
123
  }
87
124
  }
88
125
 
89
126
  return files.sort((left, right) => left.localeCompare(right));
90
127
  }
91
128
 
92
- function compilePattern(pattern) {
93
- const baseDir = patternBase(pattern);
129
+ function buildServiceRule(serviceName, serviceConfig) {
130
+ const testPrefix = normalizePath(path.posix.join("tests", serviceName));
131
+ const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
94
132
  return {
95
- baseDir,
96
- regex: globToRegExp(pattern),
133
+ name: serviceName,
134
+ testPrefix,
135
+ cwdPrefix: cwd === "." ? null : cwd,
97
136
  };
98
137
  }
99
138
 
100
- function patternBase(pattern) {
101
- const parts = pattern.split("/");
102
- const baseParts = [];
139
+ function ownsFile(serviceRule, filePath) {
140
+ if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
141
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
142
+ return false;
143
+ }
103
144
 
104
- for (const part of parts) {
105
- if (/[?*\[]/.test(part)) break;
106
- baseParts.push(part);
145
+ function relativeToServiceRoot(serviceRule, filePath) {
146
+ if (hasPrefix(filePath, serviceRule.testPrefix)) {
147
+ return path.posix.relative(serviceRule.testPrefix, filePath);
107
148
  }
108
-
109
- return baseParts.length > 0 ? baseParts.join("/") : ".";
149
+ if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
150
+ return path.posix.relative(serviceRule.cwdPrefix, filePath);
151
+ }
152
+ return filePath;
110
153
  }
111
154
 
112
- function globToRegExp(pattern) {
113
- let source = "^";
114
-
115
- for (let index = 0; index < pattern.length; index += 1) {
116
- const current = pattern[index];
117
- const next = pattern[index + 1];
118
- const afterNext = pattern[index + 2];
119
-
120
- if (current === "*" && next === "*" && afterNext === "/") {
121
- source += "(?:.*/)?";
122
- index += 2;
123
- continue;
124
- }
155
+ function deriveSuiteName(relativePath, suffix) {
156
+ const parts = relativePath.split("/").filter(Boolean);
157
+ if (parts.length >= 3) return parts[1];
158
+ return path.posix.basename(relativePath, suffix);
159
+ }
125
160
 
126
- if (current === "*" && next === "*") {
127
- source += ".*";
128
- index += 1;
129
- continue;
130
- }
161
+ function buildDiscoveryError(unowned, ambiguous) {
162
+ const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
131
163
 
132
- if (current === "*") {
133
- source += "[^/]*";
134
- continue;
164
+ if (unowned.length > 0) {
165
+ lines.push("");
166
+ lines.push("Unowned test files:");
167
+ for (const filePath of unowned) {
168
+ lines.push(`- ${filePath}`);
135
169
  }
170
+ }
136
171
 
137
- if (current === "?") {
138
- source += "[^/]";
139
- continue;
172
+ if (ambiguous.length > 0) {
173
+ lines.push("");
174
+ lines.push("Ambiguous test files:");
175
+ for (const entry of ambiguous) {
176
+ lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
140
177
  }
178
+ }
141
179
 
142
- if ("\\.[]{}()+-^$|".includes(current)) {
143
- source += `\\${current}`;
144
- continue;
145
- }
180
+ lines.push("");
181
+ lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
182
+ return new Error(lines.join("\n"));
183
+ }
146
184
 
147
- source += current;
148
- }
185
+ function inferRule(filePath) {
186
+ return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
187
+ }
149
188
 
150
- source += "$";
151
- return new RegExp(source);
189
+ function hasPrefix(filePath, prefix) {
190
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
152
191
  }
153
192
 
154
193
  function normalizePath(value) {
155
- return value.split(path.sep).join("/");
194
+ const normalized = String(value).split(path.sep).join("/");
195
+ if (normalized === "." || normalized === "./") return ".";
196
+ return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
156
197
  }
@@ -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 { discoverServiceSuites } from "./discovery.mjs";
5
+ import { discoverSuites } from "./discovery.mjs";
6
6
 
7
7
  const cleanups = [];
8
8
 
@@ -12,33 +12,91 @@ afterEach(() => {
12
12
  }
13
13
  });
14
14
 
15
- describe("config-discovery", () => {
16
- it("discovers exact-file includes with stable suite names", () => {
15
+ describe("filesystem-discovery", () => {
16
+ it("discovers tests by convention without config registration", () => {
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
- const filePath = path.join(
21
- productDir,
22
- "tests",
23
- "api",
24
- "integration",
25
- "health.int.testkit.ts"
26
- );
27
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
28
- fs.writeFileSync(filePath, "export {};\n");
29
-
30
- const suites = discoverServiceSuites(productDir, "api", {
31
- discovery: {
32
- include: ["tests/api/integration/health.int.testkit.ts"],
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");
23
+
24
+ const suites = discoverSuites(productDir, {
25
+ api: {
26
+ local: {
27
+ cwd: ".",
28
+ },
29
+ },
30
+ frontend: {
31
+ local: {
32
+ cwd: "frontend",
33
+ },
33
34
  },
34
35
  });
35
36
 
36
- expect(suites.integration).toEqual([
37
+ expect(suites.api.integration).toEqual([
38
+ {
39
+ name: "auth",
40
+ files: ["tests/api/integration/auth/me.int.testkit.ts"],
41
+ framework: "k6",
42
+ },
37
43
  {
38
44
  name: "health",
39
45
  files: ["tests/api/integration/health.int.testkit.ts"],
40
46
  framework: "k6",
41
47
  },
42
48
  ]);
49
+ expect(suites.frontend.e2e).toEqual([
50
+ {
51
+ name: "homepage",
52
+ files: ["frontend/e2e/homepage.pw.testkit.ts"],
53
+ framework: "playwright",
54
+ },
55
+ ]);
56
+ });
57
+
58
+ it("fails when a discovered file does not map to any configured service", () => {
59
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
60
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
61
+
62
+ writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
63
+
64
+ expect(() =>
65
+ discoverSuites(productDir, {
66
+ api: {
67
+ local: {
68
+ cwd: ".",
69
+ },
70
+ },
71
+ })
72
+ ).toThrow("Unowned test files");
73
+ });
74
+
75
+ it("fails when a discovered file maps to multiple services", () => {
76
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
77
+ cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
78
+
79
+ writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
80
+
81
+ expect(() =>
82
+ discoverSuites(productDir, {
83
+ frontend: {
84
+ local: {
85
+ cwd: "frontend",
86
+ },
87
+ },
88
+ web: {
89
+ local: {
90
+ cwd: "frontend",
91
+ },
92
+ },
93
+ })
94
+ ).toThrow("Ambiguous test files");
43
95
  });
44
96
  });
97
+
98
+ function writeFile(productDir, relativePath) {
99
+ const absolutePath = path.join(productDir, relativePath);
100
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
101
+ fs.writeFileSync(absolutePath, "export {};\n");
102
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { discoverServiceSuites } from "./discovery.mjs";
4
+ import { discoverSuites } from "./discovery.mjs";
5
5
  import {
6
6
  getServiceEnvFiles as getServiceEnvFilesModel,
7
7
  isObject as isObjectModel,
@@ -34,6 +34,7 @@ export function loadConfigs(opts = {}) {
34
34
  const productDir = resolveProductDir(process.cwd(), opts.dir);
35
35
  const config = loadTestkitConfig(productDir);
36
36
  validateConfigCoverage(config);
37
+ const discoveredSuites = discoverSuites(productDir, config.services);
37
38
 
38
39
  const serviceEntries = Object.entries(config.services);
39
40
  const filtered = opts.service
@@ -46,7 +47,7 @@ export function loadConfigs(opts = {}) {
46
47
  }
47
48
 
48
49
  return filtered.map(([name, serviceConfig]) => {
49
- const suites = discoverServiceSuites(productDir, name, serviceConfig);
50
+ const suites = discoveredSuites[name] || {};
50
51
  const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
51
52
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
52
53
  const selectedBackend = resolvedDatabase?.selectedBackend;
@@ -213,23 +213,9 @@ export function validateServiceConfig(name, service, configPath) {
213
213
  }
214
214
 
215
215
  if (service.discovery !== undefined) {
216
- if (!isObject(service.discovery)) {
217
- throw new Error(`Service "${name}" discovery must be an object`);
218
- }
219
- if (
220
- service.discovery.include !== undefined &&
221
- (!Array.isArray(service.discovery.include) ||
222
- service.discovery.include.some((value) => typeof value !== "string" || value.length === 0))
223
- ) {
224
- throw new Error(`Service "${name}" discovery.include must be an array of glob strings`);
225
- }
226
- if (
227
- service.discovery.exclude !== undefined &&
228
- (!Array.isArray(service.discovery.exclude) ||
229
- service.discovery.exclude.some((value) => typeof value !== "string" || value.length === 0))
230
- ) {
231
- throw new Error(`Service "${name}" discovery.exclude must be an array of glob strings`);
232
- }
216
+ throw new Error(
217
+ `Service "${name}" cannot define discovery. Testkit discovers *.testkit.ts files from the filesystem automatically.`
218
+ );
233
219
  }
234
220
  }
235
221
 
@@ -95,11 +95,9 @@ QUX='zap'
95
95
 
96
96
  expect(() =>
97
97
  validateServiceConfig("api", {
98
- discovery: {
99
- include: "tests/**/*.int.testkit.ts",
100
- },
98
+ discovery: {},
101
99
  }, "testkit.config.json")
102
- ).toThrow("discovery.include must be an array of glob strings");
100
+ ).toThrow("cannot define discovery");
103
101
 
104
102
  expect(() =>
105
103
  validateTelemetryConfig(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "CLI for discovering and running local test suites across k6 and Playwright",
5
5
  "type": "module",
6
6
  "exports": {