@elench/testkit 0.1.16 → 0.1.18
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 +44 -19
- package/bin/testkit.mjs +1 -1
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +88 -0
- package/lib/config/index.mjs +294 -0
- package/lib/config/index.test.mjs +12 -0
- package/lib/config/model.mjs +422 -0
- package/lib/config/model.test.mjs +193 -0
- package/lib/database/fingerprint.mjs +61 -0
- package/lib/database/fingerprint.test.mjs +93 -0
- package/lib/{database.mjs → database/index.mjs} +45 -160
- package/lib/database/naming.mjs +47 -0
- package/lib/database/naming.test.mjs +39 -0
- package/lib/database/state.mjs +52 -0
- package/lib/database/state.test.mjs +66 -0
- package/lib/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/runner/index.mjs +1221 -0
- package/lib/runner/metadata.mjs +55 -0
- package/lib/runner/metadata.test.mjs +52 -0
- package/lib/runner/planning.mjs +270 -0
- package/lib/runner/planning.test.mjs +127 -0
- package/lib/runner/results.mjs +285 -0
- package/lib/runner/results.test.mjs +144 -0
- package/lib/runner/state.mjs +71 -0
- package/lib/runner/state.test.mjs +64 -0
- package/lib/runner/template.mjs +320 -0
- package/lib/runner/template.test.mjs +150 -0
- package/lib/telemetry/index.mjs +43 -0
- package/lib/timing/index.mjs +73 -0
- package/lib/timing/index.test.mjs +64 -0
- package/package.json +11 -3
- package/infra/neon-down.sh +0 -18
- package/infra/neon-up.sh +0 -124
- package/lib/cli.mjs +0 -132
- package/lib/config.mjs +0 -666
- package/lib/exec.mjs +0 -20
- package/lib/runner.mjs +0 -1165
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
export const VALID_FRAMEWORKS = new Set(["k6", "playwright"]);
|
|
2
|
+
export const VALID_DB_PROVIDERS = new Set(["local"]);
|
|
3
|
+
|
|
4
|
+
export function parseDotenvString(source) {
|
|
5
|
+
const env = {};
|
|
6
|
+
for (const line of String(source).split("\n")) {
|
|
7
|
+
const trimmed = line.trim();
|
|
8
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
9
|
+
const eq = trimmed.indexOf("=");
|
|
10
|
+
if (eq === -1) continue;
|
|
11
|
+
const key = trimmed.slice(0, eq).trim();
|
|
12
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
13
|
+
if (
|
|
14
|
+
(val.startsWith("'") && val.endsWith("'")) ||
|
|
15
|
+
(val.startsWith("\"") && val.endsWith("\""))
|
|
16
|
+
) {
|
|
17
|
+
val = val.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
env[key] = val;
|
|
20
|
+
}
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateRunnerManifest(raw, manifestName = "runner.manifest.json", manifestPath = manifestName) {
|
|
25
|
+
if (!isObject(raw.services)) {
|
|
26
|
+
throw new Error(`${manifestName} must have a "services" object (${manifestPath})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [serviceName, service] of Object.entries(raw.services)) {
|
|
30
|
+
if (!isObject(service)) {
|
|
31
|
+
throw new Error(`Service "${serviceName}" in ${manifestName} must be an object`);
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(service.suites)) {
|
|
34
|
+
throw new Error(`Service "${serviceName}" in ${manifestName} must define suites`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [suiteType, suites] of Object.entries(service.suites)) {
|
|
38
|
+
if (!Array.isArray(suites)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Service "${serviceName}" suite type "${suiteType}" must be an array`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const seenNames = new Set();
|
|
45
|
+
for (const suite of suites) {
|
|
46
|
+
if (!isObject(suite)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Service "${serviceName}" suite type "${suiteType}" contains a non-object suite`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (typeof suite.name !== "string" || !suite.name.length) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Service "${serviceName}" suite type "${suiteType}" has a suite with no name`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (seenNames.has(suite.name)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Service "${serviceName}" suite type "${suiteType}" has duplicate suite name "${suite.name}"`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
seenNames.add(suite.name);
|
|
62
|
+
|
|
63
|
+
if (!Array.isArray(suite.files) || suite.files.length === 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Service "${serviceName}" suite "${suite.name}" must define one or more files`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
for (const file of suite.files) {
|
|
69
|
+
if (typeof file !== "string" || !file.length) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Service "${serviceName}" suite "${suite.name}" contains an invalid file entry`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const framework = suite.framework || "k6";
|
|
77
|
+
if (!VALID_FRAMEWORKS.has(framework)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (suite.testkit !== undefined) {
|
|
84
|
+
if (!isObject(suite.testkit)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Service "${serviceName}" suite "${suite.name}" testkit config must be an object`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
suite.testkit.maxFileConcurrency !== undefined &&
|
|
91
|
+
(!Number.isInteger(suite.testkit.maxFileConcurrency) ||
|
|
92
|
+
suite.testkit.maxFileConcurrency <= 0)
|
|
93
|
+
) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Service "${serviceName}" suite "${suite.name}" testkit.maxFileConcurrency must be a positive integer`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
suite.testkit.weight !== undefined &&
|
|
100
|
+
(!Number.isInteger(suite.testkit.weight) || suite.testkit.weight <= 0)
|
|
101
|
+
) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Service "${serviceName}" suite "${suite.name}" testkit.weight must be a positive integer`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateConfigCoverage(
|
|
113
|
+
runner,
|
|
114
|
+
config,
|
|
115
|
+
configName = "testkit.config.json",
|
|
116
|
+
manifestName = "runner.manifest.json"
|
|
117
|
+
) {
|
|
118
|
+
for (const serviceName of Object.keys(config.services || {})) {
|
|
119
|
+
if (!runner.services[serviceName]) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Service "${serviceName}" exists in ${configName} but not in ${manifestName}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const depName of config.services[serviceName].dependsOn || []) {
|
|
126
|
+
if (!config.services[depName]) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${configName}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (!runner.services[depName]) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${manifestName}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const databaseFrom = config.services[serviceName].databaseFrom;
|
|
139
|
+
if (databaseFrom) {
|
|
140
|
+
if (!config.services[databaseFrom]) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${configName}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (!runner.services[databaseFrom]) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${manifestName}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function validateServiceConfig(name, service, configPath) {
|
|
155
|
+
if (!isObject(service)) {
|
|
156
|
+
throw new Error(`Service "${name}" in ${configPath} must be an object`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (service.dependsOn !== undefined) {
|
|
160
|
+
if (!Array.isArray(service.dependsOn) || service.dependsOn.some((v) => typeof v !== "string")) {
|
|
161
|
+
throw new Error(`Service "${name}" dependsOn must be an array of service names`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (service.databaseFrom !== undefined && typeof service.databaseFrom !== "string") {
|
|
166
|
+
throw new Error(`Service "${name}" databaseFrom must be a string`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (service.database !== undefined && service.databaseFrom !== undefined) {
|
|
170
|
+
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (service.envFile !== undefined && typeof service.envFile !== "string") {
|
|
174
|
+
throw new Error(`Service "${name}" envFile must be a string`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
service.envFiles !== undefined &&
|
|
179
|
+
(!Array.isArray(service.envFiles) || service.envFiles.some((v) => typeof v !== "string"))
|
|
180
|
+
) {
|
|
181
|
+
throw new Error(`Service "${name}" envFiles must be an array of strings`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (service.database !== undefined) {
|
|
185
|
+
if (!isObject(service.database)) {
|
|
186
|
+
throw new Error(`Service "${name}" database must be an object`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (service.database.provider !== undefined) {
|
|
190
|
+
validateDatabaseProviderConfig(name, service.database, `Service "${name}" database`);
|
|
191
|
+
} else if (service.database.backends !== undefined) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Service "${name}" database.backends is no longer supported. Define a single local database provider instead.`
|
|
194
|
+
);
|
|
195
|
+
} else {
|
|
196
|
+
throw new Error(`Service "${name}" database must define provider`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (service.migrate !== undefined) {
|
|
201
|
+
validateLifecycleConfig(name, service.migrate, "migrate");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (service.seed !== undefined) {
|
|
205
|
+
validateLifecycleConfig(name, service.seed, "seed");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (service.local !== undefined) {
|
|
209
|
+
if (!isObject(service.local)) {
|
|
210
|
+
throw new Error(`Service "${name}" local must be an object`);
|
|
211
|
+
}
|
|
212
|
+
requireString(service.local, "start", `Service "${name}" local.start`);
|
|
213
|
+
requireString(service.local, "baseUrl", `Service "${name}" local.baseUrl`);
|
|
214
|
+
requireString(service.local, "readyUrl", `Service "${name}" local.readyUrl`);
|
|
215
|
+
if (
|
|
216
|
+
service.local.port !== undefined &&
|
|
217
|
+
(!Number.isInteger(service.local.port) || service.local.port <= 0)
|
|
218
|
+
) {
|
|
219
|
+
throw new Error(`Service "${name}" local.port must be a positive integer`);
|
|
220
|
+
}
|
|
221
|
+
if (service.local.cwd !== undefined && typeof service.local.cwd !== "string") {
|
|
222
|
+
throw new Error(`Service "${name}" local.cwd must be a string`);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
service.local.readyTimeoutMs !== undefined &&
|
|
226
|
+
(!Number.isInteger(service.local.readyTimeoutMs) || service.local.readyTimeoutMs <= 0)
|
|
227
|
+
) {
|
|
228
|
+
throw new Error(`Service "${name}" local.readyTimeoutMs must be a positive integer`);
|
|
229
|
+
}
|
|
230
|
+
if (service.local.env !== undefined && !isObject(service.local.env)) {
|
|
231
|
+
throw new Error(`Service "${name}" local.env must be an object`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function validateTelemetryConfig(
|
|
237
|
+
telemetry,
|
|
238
|
+
configPath,
|
|
239
|
+
configName = "testkit.config.json"
|
|
240
|
+
) {
|
|
241
|
+
if (!isObject(telemetry)) {
|
|
242
|
+
throw new Error(`${configName} telemetry must be an object (${configPath})`);
|
|
243
|
+
}
|
|
244
|
+
if (telemetry.enabled !== undefined && typeof telemetry.enabled !== "boolean") {
|
|
245
|
+
throw new Error(`${configName} telemetry.enabled must be a boolean (${configPath})`);
|
|
246
|
+
}
|
|
247
|
+
if (telemetry.endpoint !== undefined) {
|
|
248
|
+
requireString(telemetry, "endpoint", `${configName} telemetry.endpoint`);
|
|
249
|
+
validateHttpUrl(telemetry.endpoint, `${configName} telemetry.endpoint`);
|
|
250
|
+
}
|
|
251
|
+
if (telemetry.tokenEnv !== undefined) {
|
|
252
|
+
requireString(telemetry, "tokenEnv", `${configName} telemetry.tokenEnv`);
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
telemetry.timeoutMs !== undefined &&
|
|
256
|
+
(!Number.isInteger(telemetry.timeoutMs) || telemetry.timeoutMs <= 0)
|
|
257
|
+
) {
|
|
258
|
+
throw new Error(`${configName} telemetry.timeoutMs must be a positive integer (${configPath})`);
|
|
259
|
+
}
|
|
260
|
+
if (telemetry.enabled === true) {
|
|
261
|
+
requireString(telemetry, "endpoint", `${configName} telemetry.endpoint`);
|
|
262
|
+
requireString(telemetry, "tokenEnv", `${configName} telemetry.tokenEnv`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getServiceEnvFiles(serviceConfig) {
|
|
267
|
+
const files = [];
|
|
268
|
+
if (serviceConfig.envFile) files.push(serviceConfig.envFile);
|
|
269
|
+
if (Array.isArray(serviceConfig.envFiles)) {
|
|
270
|
+
files.push(...serviceConfig.envFiles);
|
|
271
|
+
}
|
|
272
|
+
return files;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function resolveSelectedDatabase(name, serviceConfig) {
|
|
276
|
+
if (!serviceConfig.database) return undefined;
|
|
277
|
+
|
|
278
|
+
const database = serviceConfig.database;
|
|
279
|
+
if (database.provider !== "local") {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`Service "${name}" database.provider must be "local". Received "${database.provider}"`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...database,
|
|
287
|
+
provider: "local",
|
|
288
|
+
selectedBackend: "local",
|
|
289
|
+
reset: database.reset !== false,
|
|
290
|
+
template: normalizeTemplateConfig(database.template),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function validateDatabaseProviderConfig(name, db, label) {
|
|
295
|
+
if (!VALID_DB_PROVIDERS.has(db.provider)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`${label}.provider must be one of: ${[...VALID_DB_PROVIDERS].join(", ")}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (db.reset !== undefined && typeof db.reset !== "boolean") {
|
|
302
|
+
throw new Error(`${label}.reset must be a boolean`);
|
|
303
|
+
}
|
|
304
|
+
if (db.template !== undefined) {
|
|
305
|
+
validateTemplateConfig(name, db.template, `${label}.template`);
|
|
306
|
+
}
|
|
307
|
+
if (db.image !== undefined && typeof db.image !== "string") {
|
|
308
|
+
throw new Error(`${label}.image must be a string`);
|
|
309
|
+
}
|
|
310
|
+
if (db.user !== undefined && typeof db.user !== "string") {
|
|
311
|
+
throw new Error(`${label}.user must be a string`);
|
|
312
|
+
}
|
|
313
|
+
if (db.password !== undefined && typeof db.password !== "string") {
|
|
314
|
+
throw new Error(`${label}.password must be a string`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function validateTemplateConfig(name, template, label) {
|
|
319
|
+
if (!isObject(template)) {
|
|
320
|
+
throw new Error(`${label} must be an object`);
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
template.inputs !== undefined &&
|
|
324
|
+
(!Array.isArray(template.inputs) || template.inputs.some((value) => typeof value !== "string"))
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(`${label}.inputs must be an array of strings`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function normalizeTemplateConfig(template) {
|
|
331
|
+
return {
|
|
332
|
+
inputs: Array.isArray(template?.inputs) ? [...template.inputs] : [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function validateLifecycleConfig(name, value, label) {
|
|
337
|
+
if (!isObject(value)) {
|
|
338
|
+
throw new Error(`Service "${name}" ${label} must be an object`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (value.cmd !== undefined) {
|
|
342
|
+
requireString(value, "cmd", `Service "${name}" ${label}.cmd`);
|
|
343
|
+
}
|
|
344
|
+
if (value.cwd !== undefined && typeof value.cwd !== "string") {
|
|
345
|
+
throw new Error(`Service "${name}" ${label}.cwd must be a string`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (value.backends !== undefined) {
|
|
349
|
+
if (!isObject(value.backends)) {
|
|
350
|
+
throw new Error(`Service "${name}" ${label}.backends must be an object`);
|
|
351
|
+
}
|
|
352
|
+
for (const [backendName, override] of Object.entries(value.backends)) {
|
|
353
|
+
if (!isObject(override)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Service "${name}" ${label}.backends.${backendName} must be an object`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (override.cmd !== undefined) {
|
|
359
|
+
requireString(override, "cmd", `Service "${name}" ${label}.backends.${backendName}.cmd`);
|
|
360
|
+
}
|
|
361
|
+
if (override.cwd !== undefined && typeof override.cwd !== "string") {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Service "${name}" ${label}.backends.${backendName}.cwd must be a string`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (value.cmd === undefined && value.backends === undefined) {
|
|
370
|
+
throw new Error(`Service "${name}" ${label} must define cmd or backends`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function resolveLifecycleConfig(value, selectedBackend) {
|
|
375
|
+
if (!value) return value;
|
|
376
|
+
|
|
377
|
+
const override =
|
|
378
|
+
selectedBackend && isObject(value.backends) ? value.backends[selectedBackend] || {} : {};
|
|
379
|
+
const resolved = {
|
|
380
|
+
...value,
|
|
381
|
+
...override,
|
|
382
|
+
};
|
|
383
|
+
delete resolved.backends;
|
|
384
|
+
return resolved;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function requireString(obj, key, label) {
|
|
388
|
+
if (typeof obj[key] !== "string" || obj[key].length === 0) {
|
|
389
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function isDalSuiteType(suite, runnerService, suitesForType) {
|
|
394
|
+
if (suite.framework && suite.framework !== "k6") return false;
|
|
395
|
+
return suitesForType === runnerService.suites.dal;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function isObject(value) {
|
|
399
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function normalizeTelemetryConfig(telemetry) {
|
|
403
|
+
if (!isObject(telemetry)) return null;
|
|
404
|
+
return {
|
|
405
|
+
enabled: telemetry.enabled === true,
|
|
406
|
+
endpoint: telemetry.endpoint || null,
|
|
407
|
+
tokenEnv: telemetry.tokenEnv || null,
|
|
408
|
+
timeoutMs: telemetry.timeoutMs || 3_000,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function validateHttpUrl(value, label) {
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
parsed = new URL(value);
|
|
416
|
+
} catch {
|
|
417
|
+
throw new Error(`${label} must be a valid URL`);
|
|
418
|
+
}
|
|
419
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
420
|
+
throw new Error(`${label} must use http or https`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getServiceEnvFiles,
|
|
4
|
+
isDalSuiteType,
|
|
5
|
+
normalizeTelemetryConfig,
|
|
6
|
+
parseDotenvString,
|
|
7
|
+
resolveLifecycleConfig,
|
|
8
|
+
resolveSelectedDatabase,
|
|
9
|
+
validateConfigCoverage,
|
|
10
|
+
validateLifecycleConfig,
|
|
11
|
+
validateRunnerManifest,
|
|
12
|
+
validateServiceConfig,
|
|
13
|
+
validateTelemetryConfig,
|
|
14
|
+
} from "./model.mjs";
|
|
15
|
+
|
|
16
|
+
describe("config-model", () => {
|
|
17
|
+
it("parses dotenv text", () => {
|
|
18
|
+
expect(
|
|
19
|
+
parseDotenvString(`
|
|
20
|
+
# comment
|
|
21
|
+
FOO=bar
|
|
22
|
+
BAR="baz"
|
|
23
|
+
QUX='zap'
|
|
24
|
+
`)
|
|
25
|
+
).toEqual({
|
|
26
|
+
FOO: "bar",
|
|
27
|
+
BAR: "baz",
|
|
28
|
+
QUX: "zap",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("validates runner manifests", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
validateRunnerManifest({
|
|
35
|
+
services: {
|
|
36
|
+
api: {
|
|
37
|
+
suites: {
|
|
38
|
+
integration: [
|
|
39
|
+
{ name: "health", files: ["tests/health.js"] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
).not.toThrow();
|
|
46
|
+
|
|
47
|
+
expect(() =>
|
|
48
|
+
validateRunnerManifest({
|
|
49
|
+
services: {
|
|
50
|
+
api: {
|
|
51
|
+
suites: {
|
|
52
|
+
integration: [
|
|
53
|
+
{ name: "health", files: ["a.js"], framework: "jest" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
).toThrow("unsupported framework");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("validates config coverage", () => {
|
|
63
|
+
const runner = {
|
|
64
|
+
services: {
|
|
65
|
+
api: { suites: {} },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const config = {
|
|
69
|
+
services: {
|
|
70
|
+
frontend: {
|
|
71
|
+
dependsOn: ["api"],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
expect(() => validateConfigCoverage(runner, config)).toThrow(
|
|
77
|
+
'Service "frontend" exists in testkit.config.json but not in runner.manifest.json'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("validates service and telemetry config", () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
validateServiceConfig("api", {
|
|
84
|
+
local: {
|
|
85
|
+
start: "npm run dev",
|
|
86
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
87
|
+
readyUrl: "http://127.0.0.1:3000/health",
|
|
88
|
+
port: 3000,
|
|
89
|
+
},
|
|
90
|
+
}, "testkit.config.json")
|
|
91
|
+
).not.toThrow();
|
|
92
|
+
|
|
93
|
+
expect(() =>
|
|
94
|
+
validateServiceConfig("api", {
|
|
95
|
+
database: {
|
|
96
|
+
backends: {},
|
|
97
|
+
},
|
|
98
|
+
}, "testkit.config.json")
|
|
99
|
+
).toThrow("database.backends is no longer supported");
|
|
100
|
+
|
|
101
|
+
expect(() =>
|
|
102
|
+
validateTelemetryConfig(
|
|
103
|
+
{
|
|
104
|
+
enabled: true,
|
|
105
|
+
endpoint: "ftp://bad.example.com/upload",
|
|
106
|
+
tokenEnv: "TOKEN",
|
|
107
|
+
},
|
|
108
|
+
"testkit.config.json"
|
|
109
|
+
)
|
|
110
|
+
).toThrow("must use http or https");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("collects env files and resolves lifecycle overrides", () => {
|
|
114
|
+
expect(
|
|
115
|
+
getServiceEnvFiles({
|
|
116
|
+
envFile: ".env.shared",
|
|
117
|
+
envFiles: [".env.local"],
|
|
118
|
+
})
|
|
119
|
+
).toEqual([".env.shared", ".env.local"]);
|
|
120
|
+
|
|
121
|
+
expect(() =>
|
|
122
|
+
validateLifecycleConfig("api", { backends: {} }, "migrate")
|
|
123
|
+
).not.toThrow();
|
|
124
|
+
|
|
125
|
+
expect(
|
|
126
|
+
resolveLifecycleConfig(
|
|
127
|
+
{
|
|
128
|
+
cmd: "npm run migrate",
|
|
129
|
+
backends: {
|
|
130
|
+
local: {
|
|
131
|
+
cmd: "npm run migrate:local",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
"local"
|
|
136
|
+
)
|
|
137
|
+
).toEqual({
|
|
138
|
+
cmd: "npm run migrate:local",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("normalizes telemetry and database config", () => {
|
|
143
|
+
expect(
|
|
144
|
+
resolveSelectedDatabase("api", {
|
|
145
|
+
database: {
|
|
146
|
+
provider: "local",
|
|
147
|
+
image: "pgvector/pgvector:pg16",
|
|
148
|
+
template: {
|
|
149
|
+
inputs: ["schema"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
).toEqual({
|
|
154
|
+
provider: "local",
|
|
155
|
+
image: "pgvector/pgvector:pg16",
|
|
156
|
+
selectedBackend: "local",
|
|
157
|
+
reset: true,
|
|
158
|
+
template: {
|
|
159
|
+
inputs: ["schema"],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
normalizeTelemetryConfig({
|
|
165
|
+
enabled: true,
|
|
166
|
+
endpoint: "https://example.com",
|
|
167
|
+
tokenEnv: "TOKEN",
|
|
168
|
+
})
|
|
169
|
+
).toEqual({
|
|
170
|
+
enabled: true,
|
|
171
|
+
endpoint: "https://example.com",
|
|
172
|
+
tokenEnv: "TOKEN",
|
|
173
|
+
timeoutMs: 3000,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("detects dal suite types", () => {
|
|
178
|
+
const suite = { name: "db", files: ["a.js"] };
|
|
179
|
+
const runnerService = {
|
|
180
|
+
suites: {
|
|
181
|
+
dal: [suite],
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
expect(isDalSuiteType(suite, runnerService, runnerService.suites.dal)).toBe(true);
|
|
185
|
+
expect(
|
|
186
|
+
isDalSuiteType(
|
|
187
|
+
{ ...suite, framework: "playwright" },
|
|
188
|
+
runnerService,
|
|
189
|
+
runnerService.suites.dal
|
|
190
|
+
)
|
|
191
|
+
).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { resolveServiceCwd } from "../config/index.mjs";
|
|
5
|
+
|
|
6
|
+
const LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
7
|
+
const LOCAL_USER = "testkit";
|
|
8
|
+
|
|
9
|
+
export async function computeTemplateFingerprint(config) {
|
|
10
|
+
const hash = crypto.createHash("sha256");
|
|
11
|
+
const db = config.testkit.database;
|
|
12
|
+
hash.update(JSON.stringify({
|
|
13
|
+
provider: db.provider,
|
|
14
|
+
selectedBackend: db.selectedBackend,
|
|
15
|
+
image: db.image || LOCAL_IMAGE,
|
|
16
|
+
user: db.user || LOCAL_USER,
|
|
17
|
+
migrate: config.testkit.migrate || null,
|
|
18
|
+
seed: config.testkit.seed || null,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
for (const envFile of config.testkit.envFiles || []) {
|
|
22
|
+
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
|
|
23
|
+
}
|
|
24
|
+
for (const input of db.template.inputs || []) {
|
|
25
|
+
appendInputToHash(hash, config.productDir, input);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return hash.digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function appendInputToHash(hash, productDir, input) {
|
|
32
|
+
const absPath = resolveServiceCwd(productDir, input);
|
|
33
|
+
if (!fs.existsSync(absPath)) {
|
|
34
|
+
hash.update(`missing:${path.relative(productDir, absPath)}`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stat = fs.statSync(absPath);
|
|
39
|
+
if (stat.isDirectory()) {
|
|
40
|
+
hash.update(`dir:${path.relative(productDir, absPath)}`);
|
|
41
|
+
for (const entry of fs.readdirSync(absPath).sort()) {
|
|
42
|
+
if (entry === ".git" || entry === "node_modules" || entry === ".testkit") continue;
|
|
43
|
+
appendInputToHash(hash, productDir, path.join(input, entry));
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
appendFileToHash(hash, productDir, absPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function appendFileToHash(hash, productDir, absPath) {
|
|
52
|
+
if (!fs.existsSync(absPath)) {
|
|
53
|
+
hash.update(`missing:${path.relative(productDir, absPath)}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const stat = fs.statSync(absPath);
|
|
57
|
+
if (!stat.isFile()) return;
|
|
58
|
+
|
|
59
|
+
hash.update(`file:${path.relative(productDir, absPath)}:${stat.size}:${stat.mtimeMs}`);
|
|
60
|
+
hash.update(fs.readFileSync(absPath));
|
|
61
|
+
}
|