@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 +2 -2
- package/lib/config/discovery.mjs +140 -99
- package/lib/config/discovery.test.mjs +75 -17
- package/lib/config/index.mjs +3 -2
- package/lib/config/model.mjs +3 -17
- package/lib/config/model.test.mjs +2 -4
- package/package.json +1 -1
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,
|
|
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
|
-
- `
|
|
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
|
package/lib/config/discovery.mjs
CHANGED
|
@@ -9,148 +9,189 @@ const DISCOVERY_RULES = [
|
|
|
9
9
|
{ suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
78
|
+
};
|
|
79
|
+
suitesForType.push(suite);
|
|
80
|
+
grouped[rule.type] = suitesForType;
|
|
43
81
|
}
|
|
44
|
-
}
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
|
|
83
|
+
suite.files.push(filePath);
|
|
48
84
|
}
|
|
49
85
|
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
if (unowned.length > 0 || ambiguous.length > 0) {
|
|
87
|
+
throw buildDiscoveryError(unowned, ambiguous);
|
|
88
|
+
}
|
|
52
89
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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 = [
|
|
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
|
|
109
|
+
const absolutePath = path.join(current, entry.name);
|
|
110
|
+
|
|
111
|
+
if (entry.isSymbolicLink()) continue;
|
|
112
|
+
|
|
80
113
|
if (entry.isDirectory()) {
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
const
|
|
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
|
-
|
|
96
|
-
|
|
133
|
+
name: serviceName,
|
|
134
|
+
testPrefix,
|
|
135
|
+
cwdPrefix: cwd === "." ? null : cwd,
|
|
97
136
|
};
|
|
98
137
|
}
|
|
99
138
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
function relativeToServiceRoot(serviceRule, filePath) {
|
|
146
|
+
if (hasPrefix(filePath, serviceRule.testPrefix)) {
|
|
147
|
+
return path.posix.relative(serviceRule.testPrefix, filePath);
|
|
107
148
|
}
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
185
|
+
function inferRule(filePath) {
|
|
186
|
+
return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
|
|
187
|
+
}
|
|
149
188
|
|
|
150
|
-
|
|
151
|
-
return
|
|
189
|
+
function hasPrefix(filePath, prefix) {
|
|
190
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
function normalizePath(value) {
|
|
155
|
-
|
|
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 {
|
|
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("
|
|
16
|
-
it("discovers
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
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 =
|
|
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;
|
package/lib/config/model.mjs
CHANGED
|
@@ -213,23 +213,9 @@ export function validateServiceConfig(name, service, configPath) {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
if (service.discovery !== undefined) {
|
|
216
|
-
|
|
217
|
-
|
|
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("
|
|
100
|
+
).toThrow("cannot define discovery");
|
|
103
101
|
|
|
104
102
|
expect(() =>
|
|
105
103
|
validateTelemetryConfig(
|