@elench/testkit 0.1.60 → 0.1.62
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/lib/config/database.mjs +53 -0
- package/lib/config/database.test.mjs +29 -0
- package/lib/config/discovery-config.mjs +13 -0
- package/lib/config/env.mjs +55 -0
- package/lib/config/env.test.mjs +40 -0
- package/lib/config/index.mjs +21 -807
- package/lib/config/paths.mjs +28 -0
- package/lib/config/paths.test.mjs +27 -0
- package/lib/config/runtime.mjs +241 -0
- package/lib/config/runtime.test.mjs +56 -0
- package/lib/config/skip-config.mjs +189 -0
- package/lib/config/skip-config.test.mjs +63 -0
- package/lib/config/telemetry.mjs +28 -0
- package/lib/config/validation.mjs +124 -0
- package/lib/coverage/backend-discovery.mjs +183 -0
- package/lib/coverage/backend-discovery.test.mjs +52 -0
- package/lib/coverage/evidence.mjs +147 -0
- package/lib/coverage/evidence.test.mjs +77 -0
- package/lib/coverage/fs-walk.mjs +64 -0
- package/lib/coverage/graph-builder.mjs +181 -0
- package/lib/coverage/index.mjs +1 -816
- package/lib/coverage/index.test.mjs +330 -14
- package/lib/coverage/next-discovery.mjs +174 -0
- package/lib/coverage/next-static-analysis.mjs +763 -0
- package/lib/coverage/routing.mjs +86 -0
- package/lib/coverage/routing.test.mjs +52 -0
- package/lib/coverage/shared.mjs +198 -0
- package/lib/coverage/shared.test.mjs +39 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-bridge/src/index.mjs +156 -13
- package/node_modules/@elench/testkit-bridge/src/index.test.mjs +39 -5
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/testkit-protocol/src/index.d.ts +26 -0
- package/node_modules/@elench/testkit-protocol/src/index.mjs +18 -0
- package/node_modules/@elench/testkit-protocol/src/index.test.mjs +75 -1
- package/package.json +5 -4
package/lib/config/index.mjs
CHANGED
|
@@ -3,37 +3,28 @@ import path from "path";
|
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { discoverProject } from "./discovery.mjs";
|
|
5
5
|
import { loadTestkitSetup } from "./setup-loader.mjs";
|
|
6
|
+
import { normalizeToolchainRegistry } from "../toolchains/index.mjs";
|
|
7
|
+
import { mergeDiscoveryConfigs } from "../discovery/path-policy.mjs";
|
|
8
|
+
import { normalizeDatabaseConfig } from "./database.mjs";
|
|
9
|
+
import { normalizeRepoDiscoveryConfig, normalizeServiceDiscoveryConfig } from "./discovery-config.mjs";
|
|
10
|
+
import { inferEnvFiles, loadServiceEnv, parseDotenv } from "./env.mjs";
|
|
11
|
+
import { ensureExistingPath, resolveProductDir, resolveServiceCwd } from "./paths.mjs";
|
|
6
12
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "
|
|
18
|
-
import { normalizeKnownFailureIssueValidationConfig } from "../known-failures/github.mjs";
|
|
19
|
-
import {
|
|
20
|
-
normalizeRuntimeToolchain,
|
|
21
|
-
normalizeToolchainRegistry,
|
|
22
|
-
} from "../toolchains/index.mjs";
|
|
23
|
-
import {
|
|
24
|
-
mergeDiscoveryConfigs,
|
|
25
|
-
normalizeDiscoveryConfig,
|
|
26
|
-
} from "../discovery/path-policy.mjs";
|
|
13
|
+
detectNextApp,
|
|
14
|
+
inferLocalRuntime,
|
|
15
|
+
normalizeBrowserServiceConfig,
|
|
16
|
+
normalizeOptionalString,
|
|
17
|
+
normalizeRepoExecution,
|
|
18
|
+
normalizeReportingConfig,
|
|
19
|
+
normalizeRuntimeConfig,
|
|
20
|
+
} from "./runtime.mjs";
|
|
21
|
+
import { normalizeServiceRequirements, normalizeSkipConfig } from "./skip-config.mjs";
|
|
22
|
+
import { normalizeTelemetryConfig } from "./telemetry.mjs";
|
|
23
|
+
import { validateConfigCoverage, validateServiceConfig } from "./validation.mjs";
|
|
27
24
|
|
|
28
25
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
29
|
-
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
30
|
-
const DEFAULT_LOCAL_USER = "testkit";
|
|
31
|
-
const DEFAULT_LOCAL_PASSWORD = "testkit";
|
|
32
26
|
|
|
33
|
-
export
|
|
34
|
-
if (!fs.existsSync(filePath)) return {};
|
|
35
|
-
return parseDotenvString(fs.readFileSync(filePath, "utf8"));
|
|
36
|
-
}
|
|
27
|
+
export { parseDotenv, resolveProductDir, resolveServiceCwd };
|
|
37
28
|
|
|
38
29
|
export async function loadConfigContext(opts = {}) {
|
|
39
30
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
@@ -91,9 +82,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
91
82
|
const context = await loadConfigContext(opts);
|
|
92
83
|
const { configs } = context;
|
|
93
84
|
|
|
94
|
-
const filtered = opts.service
|
|
95
|
-
? configs.filter((config) => config.name === opts.service)
|
|
96
|
-
: configs;
|
|
85
|
+
const filtered = opts.service ? configs.filter((config) => config.name === opts.service) : configs;
|
|
97
86
|
|
|
98
87
|
if (opts.service && filtered.length === 0) {
|
|
99
88
|
const available = configs.map((config) => config.name).join(", ");
|
|
@@ -132,10 +121,6 @@ export function resolveDalBinary() {
|
|
|
132
121
|
return resolveK6Binary();
|
|
133
122
|
}
|
|
134
123
|
|
|
135
|
-
export function resolveServiceCwd(productDir, maybeRelative) {
|
|
136
|
-
return path.resolve(productDir, maybeRelative || ".");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
124
|
function normalizeServiceConfig({
|
|
140
125
|
name,
|
|
141
126
|
productDir,
|
|
@@ -167,15 +152,8 @@ function normalizeServiceConfig({
|
|
|
167
152
|
}
|
|
168
153
|
const database = normalizeDatabaseConfig(explicitService, name);
|
|
169
154
|
const runtime = normalizeRuntimeConfig(explicitService.runtime, name, toolchains);
|
|
170
|
-
const skip = normalizeSkipConfig(explicitService.skip, {
|
|
171
|
-
|
|
172
|
-
productDir,
|
|
173
|
-
suites,
|
|
174
|
-
});
|
|
175
|
-
const requirements = normalizeServiceRequirements(explicitService.requirements, {
|
|
176
|
-
name,
|
|
177
|
-
suites,
|
|
178
|
-
});
|
|
155
|
+
const skip = normalizeSkipConfig(explicitService.skip, { name, suites });
|
|
156
|
+
const requirements = normalizeServiceRequirements(explicitService.requirements, { name, suites });
|
|
179
157
|
|
|
180
158
|
validateServiceConfig({
|
|
181
159
|
name,
|
|
@@ -213,34 +191,6 @@ function normalizeServiceConfig({
|
|
|
213
191
|
};
|
|
214
192
|
}
|
|
215
193
|
|
|
216
|
-
function normalizeRepoDiscoveryConfig(value) {
|
|
217
|
-
return normalizeDiscoveryConfig(value, { allowRoots: true });
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function normalizeServiceDiscoveryConfig(value, serviceName) {
|
|
221
|
-
try {
|
|
222
|
-
return normalizeDiscoveryConfig(value, { allowRoots: true });
|
|
223
|
-
} catch (error) {
|
|
224
|
-
throw new Error(`Service "${serviceName}" has invalid discovery config: ${error.message}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function normalizeReportingConfig(value) {
|
|
229
|
-
if (!value) return null;
|
|
230
|
-
|
|
231
|
-
const knownFailuresFile = normalizeOptionalString(value.knownFailuresFile);
|
|
232
|
-
if (!knownFailuresFile) {
|
|
233
|
-
throw new Error('testkit.setup.ts reporting.knownFailuresFile must be a non-empty string');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const issueValidation = normalizeKnownFailureIssueValidationConfig(value.issueValidation);
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
knownFailuresFile,
|
|
240
|
-
issueValidation,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
194
|
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
245
195
|
if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
|
|
246
196
|
if (explicitService.local === false) {
|
|
@@ -259,739 +209,3 @@ function normalizeLocalConfig(name, explicitService, discoveredService, productD
|
|
|
259
209
|
|
|
260
210
|
return detected;
|
|
261
211
|
}
|
|
262
|
-
|
|
263
|
-
function inferLocalRuntime(productDir, cwd) {
|
|
264
|
-
const absoluteCwd = resolveServiceCwd(productDir, cwd);
|
|
265
|
-
if (!fs.existsSync(absoluteCwd)) return undefined;
|
|
266
|
-
|
|
267
|
-
if (detectNextApp(absoluteCwd)) {
|
|
268
|
-
return {
|
|
269
|
-
cwd,
|
|
270
|
-
start: "./node_modules/.bin/next dev -p {port}",
|
|
271
|
-
port: 3000,
|
|
272
|
-
baseUrl: "http://127.0.0.1:{port}",
|
|
273
|
-
readyUrl: "http://127.0.0.1:{port}",
|
|
274
|
-
env: {},
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
|
|
279
|
-
return {
|
|
280
|
-
cwd,
|
|
281
|
-
start: "go run ./cmd/server",
|
|
282
|
-
port: 3000,
|
|
283
|
-
baseUrl: "http://127.0.0.1:{port}",
|
|
284
|
-
readyUrl: "http://127.0.0.1:{port}/health",
|
|
285
|
-
env: {},
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
|
|
290
|
-
return {
|
|
291
|
-
cwd,
|
|
292
|
-
start: "./node_modules/.bin/tsx watch src/index.ts",
|
|
293
|
-
port: 3000,
|
|
294
|
-
baseUrl: "http://127.0.0.1:{port}",
|
|
295
|
-
readyUrl: "http://127.0.0.1:{port}/health",
|
|
296
|
-
env: {},
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return undefined;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
304
|
-
if (explicitService.databaseFrom) return undefined;
|
|
305
|
-
if (!explicitService.database) return undefined;
|
|
306
|
-
|
|
307
|
-
const database =
|
|
308
|
-
explicitService.database.provider === "local"
|
|
309
|
-
? explicitService.database
|
|
310
|
-
: {
|
|
311
|
-
provider: "local",
|
|
312
|
-
...explicitService.database,
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
...database,
|
|
317
|
-
binding: normalizeDatabaseBinding(
|
|
318
|
-
database.binding || "per-runtime",
|
|
319
|
-
`Service "${serviceName}" database.binding`
|
|
320
|
-
),
|
|
321
|
-
provider: "local",
|
|
322
|
-
selectedBackend: "local",
|
|
323
|
-
reset: database.reset !== false,
|
|
324
|
-
image: database.image || DEFAULT_LOCAL_IMAGE,
|
|
325
|
-
user: database.user || DEFAULT_LOCAL_USER,
|
|
326
|
-
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
327
|
-
template: normalizeDatabaseTemplateConfig(database.template, serviceName),
|
|
328
|
-
serviceName,
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function normalizeRuntimeConfig(value, serviceName, toolchains) {
|
|
333
|
-
if (!value) {
|
|
334
|
-
return {
|
|
335
|
-
instances: 1,
|
|
336
|
-
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
337
|
-
prepare: {
|
|
338
|
-
inputs: [],
|
|
339
|
-
steps: [],
|
|
340
|
-
},
|
|
341
|
-
toolchain: null,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return {
|
|
346
|
-
instances: normalizeRuntimeInstances(
|
|
347
|
-
value.instances ?? 1,
|
|
348
|
-
`Service "${serviceName}" runtime.instances`
|
|
349
|
-
),
|
|
350
|
-
maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
|
|
351
|
-
value.maxConcurrentTasks,
|
|
352
|
-
`Service "${serviceName}" runtime.maxConcurrentTasks`
|
|
353
|
-
),
|
|
354
|
-
prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
|
|
355
|
-
toolchain: normalizeRuntimeToolchain(
|
|
356
|
-
value.toolchain,
|
|
357
|
-
`Service "${serviceName}" runtime.toolchain`,
|
|
358
|
-
toolchains || {}
|
|
359
|
-
),
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function normalizeRuntimePrepareConfig(value, serviceName) {
|
|
364
|
-
if (value == null) {
|
|
365
|
-
return {
|
|
366
|
-
inputs: [],
|
|
367
|
-
steps: [],
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
if (!value || typeof value !== "object") {
|
|
371
|
-
throw new Error(`Service "${serviceName}" runtime.prepare must be an object`);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
inputs: normalizeTemplateInputs(value.inputs, `Service "${serviceName}" runtime.prepare`),
|
|
376
|
-
steps: normalizeTemplateLifecycleSteps(
|
|
377
|
-
value.steps,
|
|
378
|
-
`Service "${serviceName}" runtime.prepare.steps`
|
|
379
|
-
),
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function normalizeOptionalString(value) {
|
|
384
|
-
if (typeof value !== "string") return null;
|
|
385
|
-
const normalized = value.trim();
|
|
386
|
-
return normalized.length > 0 ? normalized : null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function normalizeDatabaseTemplateConfig(value, serviceName) {
|
|
390
|
-
if (value == null) {
|
|
391
|
-
return {
|
|
392
|
-
inputs: [],
|
|
393
|
-
migrate: [],
|
|
394
|
-
seed: [],
|
|
395
|
-
verify: [],
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
if (!value || typeof value !== "object") {
|
|
399
|
-
throw new Error(`Service "${serviceName}" database.template must be an object`);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
return {
|
|
403
|
-
inputs: normalizeTemplateInputs(value.inputs, serviceName),
|
|
404
|
-
migrate: normalizeTemplateLifecycleSteps(
|
|
405
|
-
value.migrate,
|
|
406
|
-
`Service "${serviceName}" database.template.migrate`
|
|
407
|
-
),
|
|
408
|
-
seed: normalizeTemplateLifecycleSteps(
|
|
409
|
-
value.seed,
|
|
410
|
-
`Service "${serviceName}" database.template.seed`
|
|
411
|
-
),
|
|
412
|
-
verify: normalizeTemplateLifecycleSteps(
|
|
413
|
-
value.verify,
|
|
414
|
-
`Service "${serviceName}" database.template.verify`
|
|
415
|
-
),
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function normalizeTemplateInputs(value, serviceName) {
|
|
420
|
-
if (value == null) return [];
|
|
421
|
-
if (!Array.isArray(value)) {
|
|
422
|
-
throw new Error(`Service "${serviceName}" database.template.inputs must be an array`);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return value.map((entry, index) => {
|
|
426
|
-
const normalized = normalizeOptionalString(entry);
|
|
427
|
-
if (!normalized) {
|
|
428
|
-
throw new Error(
|
|
429
|
-
`Service "${serviceName}" database.template.inputs[${index}] must be a non-empty string`
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
return normalized;
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function normalizeTemplateLifecycleSteps(value, label) {
|
|
437
|
-
if (value == null) return [];
|
|
438
|
-
if (!Array.isArray(value)) {
|
|
439
|
-
throw new Error(`${label} must be an array`);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return value.map((step, index) => normalizeTemplateLifecycleStep(step, `${label}[${index}]`));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function normalizeTemplateLifecycleStep(step, label) {
|
|
446
|
-
if (!step || typeof step !== "object") {
|
|
447
|
-
throw new Error(`${label} must be an object`);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const kind = normalizeOptionalString(step.kind);
|
|
451
|
-
if (kind === "command") {
|
|
452
|
-
const cmd = normalizeOptionalString(step.cmd);
|
|
453
|
-
if (!cmd) throw new Error(`${label}.cmd must be a non-empty string`);
|
|
454
|
-
return {
|
|
455
|
-
kind,
|
|
456
|
-
cmd,
|
|
457
|
-
cwd: normalizeOptionalString(step.cwd),
|
|
458
|
-
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
if (kind === "sql-file") {
|
|
462
|
-
const filePath = normalizeOptionalString(step.path);
|
|
463
|
-
if (!filePath) throw new Error(`${label}.path must be a non-empty string`);
|
|
464
|
-
return {
|
|
465
|
-
kind,
|
|
466
|
-
path: filePath,
|
|
467
|
-
cwd: normalizeOptionalString(step.cwd),
|
|
468
|
-
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
if (kind === "module") {
|
|
472
|
-
const specifier = normalizeOptionalString(step.specifier);
|
|
473
|
-
if (!specifier) throw new Error(`${label}.specifier must be a non-empty string`);
|
|
474
|
-
return {
|
|
475
|
-
kind,
|
|
476
|
-
specifier,
|
|
477
|
-
cwd: normalizeOptionalString(step.cwd),
|
|
478
|
-
inputs: normalizeTemplateStepInputs(step.inputs, label),
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
throw new Error(`${label}.kind must be one of: command, sql-file, module`);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function normalizeTemplateStepInputs(value, label) {
|
|
486
|
-
if (value == null) return [];
|
|
487
|
-
if (!Array.isArray(value)) {
|
|
488
|
-
throw new Error(`${label}.inputs must be an array`);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return value.map((entry, index) => {
|
|
492
|
-
const normalized = normalizeOptionalString(entry);
|
|
493
|
-
if (!normalized) {
|
|
494
|
-
throw new Error(`${label}.inputs[${index}] must be a non-empty string`);
|
|
495
|
-
}
|
|
496
|
-
return normalized;
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function normalizeSkipConfig(value, { name, productDir, suites }) {
|
|
501
|
-
if (!value) return undefined;
|
|
502
|
-
|
|
503
|
-
const discoveredFiles = new Set();
|
|
504
|
-
const discoveredSuites = [];
|
|
505
|
-
for (const [type, typedSuites] of Object.entries(suites || {})) {
|
|
506
|
-
for (const suite of typedSuites || []) {
|
|
507
|
-
const displayType = suiteSelectionType(type, suite.framework || "k6");
|
|
508
|
-
discoveredSuites.push({
|
|
509
|
-
type,
|
|
510
|
-
displayType,
|
|
511
|
-
name: suite.name,
|
|
512
|
-
});
|
|
513
|
-
for (const file of suite.files || []) {
|
|
514
|
-
discoveredFiles.add(normalizePath(file));
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const seenFiles = new Set();
|
|
520
|
-
const files = [];
|
|
521
|
-
for (const rule of value.files || []) {
|
|
522
|
-
if (!rule || typeof rule !== "object") {
|
|
523
|
-
throw new Error(`Service "${name}" skip.files entries must be objects`);
|
|
524
|
-
}
|
|
525
|
-
const filePath = normalizePath(rule.path);
|
|
526
|
-
const reason = normalizeSkipReason(rule.reason, `Service "${name}" skip.files["${filePath}"]`);
|
|
527
|
-
if (!filePath) {
|
|
528
|
-
throw new Error(`Service "${name}" skip.files entries require a non-empty path`);
|
|
529
|
-
}
|
|
530
|
-
if (seenFiles.has(filePath)) {
|
|
531
|
-
throw new Error(`Service "${name}" defines duplicate skip.files path "${filePath}"`);
|
|
532
|
-
}
|
|
533
|
-
if (!discoveredFiles.has(filePath)) {
|
|
534
|
-
throw new Error(
|
|
535
|
-
`Service "${name}" skip.files path "${filePath}" did not match any discovered suite file`
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
seenFiles.add(filePath);
|
|
539
|
-
files.push({ path: filePath, reason });
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const parsedSelectors = (value.suites || []).flatMap((rule, index) => {
|
|
543
|
-
if (!rule || typeof rule !== "object") {
|
|
544
|
-
throw new Error(`Service "${name}" skip.suites entries must be objects`);
|
|
545
|
-
}
|
|
546
|
-
const selector = String(rule.selector || "").trim();
|
|
547
|
-
if (!selector) {
|
|
548
|
-
throw new Error(`Service "${name}" skip.suites[${index}] requires a non-empty selector`);
|
|
549
|
-
}
|
|
550
|
-
const reason = normalizeSkipReason(
|
|
551
|
-
rule.reason,
|
|
552
|
-
`Service "${name}" skip.suites["${selector}"]`
|
|
553
|
-
);
|
|
554
|
-
const parsed = parseSuiteSelectors([selector]);
|
|
555
|
-
if (parsed.length !== 1) {
|
|
556
|
-
throw new Error(`Service "${name}" skip.suites["${selector}"] is invalid`);
|
|
557
|
-
}
|
|
558
|
-
return [{ selector: parsed[0], reason }];
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
const seenSelectors = new Set();
|
|
562
|
-
const suitesWithReasons = [];
|
|
563
|
-
for (const rule of parsedSelectors) {
|
|
564
|
-
if (seenSelectors.has(rule.selector.raw)) {
|
|
565
|
-
throw new Error(
|
|
566
|
-
`Service "${name}" defines duplicate skip.suites selector "${rule.selector.raw}"`
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
const matched = discoveredSuites.some((suite) =>
|
|
570
|
-
matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])
|
|
571
|
-
);
|
|
572
|
-
if (!matched) {
|
|
573
|
-
throw new Error(
|
|
574
|
-
`Service "${name}" skip.suites selector "${rule.selector.raw}" did not match any discovered suite`
|
|
575
|
-
);
|
|
576
|
-
}
|
|
577
|
-
seenSelectors.add(rule.selector.raw);
|
|
578
|
-
suitesWithReasons.push(rule);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (files.length === 0 && suitesWithReasons.length === 0) return undefined;
|
|
582
|
-
|
|
583
|
-
return {
|
|
584
|
-
files,
|
|
585
|
-
fileReasonByPath: new Map(files.map((rule) => [rule.path, rule.reason])),
|
|
586
|
-
suites: suitesWithReasons,
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function normalizeServiceRequirements(value, { name, suites }) {
|
|
591
|
-
if (!value) return { suites: [], files: [], fileLocksByPath: new Map() };
|
|
592
|
-
|
|
593
|
-
const discoveredSuites = [];
|
|
594
|
-
const discoveredFiles = new Set();
|
|
595
|
-
for (const [type, typedSuites] of Object.entries(suites || {})) {
|
|
596
|
-
for (const suite of typedSuites || []) {
|
|
597
|
-
discoveredSuites.push({
|
|
598
|
-
displayType: suiteSelectionType(type, suite.framework || "k6"),
|
|
599
|
-
name: suite.name,
|
|
600
|
-
});
|
|
601
|
-
for (const file of suite.files || []) {
|
|
602
|
-
discoveredFiles.add(file);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const suiteRules = [];
|
|
608
|
-
const seenSelectors = new Set();
|
|
609
|
-
for (const rule of value.suites || []) {
|
|
610
|
-
if (!rule || typeof rule !== "object") {
|
|
611
|
-
throw new Error(`Service "${name}" requirements.suites entries must be objects`);
|
|
612
|
-
}
|
|
613
|
-
const selector = String(rule.selector || "").trim();
|
|
614
|
-
if (!selector) {
|
|
615
|
-
throw new Error(`Service "${name}" requirements.suites entries require a selector`);
|
|
616
|
-
}
|
|
617
|
-
const parsed = parseSuiteSelectors([selector]);
|
|
618
|
-
if (parsed.length !== 1) {
|
|
619
|
-
throw new Error(`Service "${name}" requirements.suites["${selector}"] is invalid`);
|
|
620
|
-
}
|
|
621
|
-
const parsedSelector = parsed[0];
|
|
622
|
-
if (parsedSelector.kind !== "typed") {
|
|
623
|
-
throw new Error(
|
|
624
|
-
`Service "${name}" requirements.suites["${selector}"] must use a typed selector like int:health`
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
if (seenSelectors.has(parsedSelector.raw)) {
|
|
628
|
-
throw new Error(
|
|
629
|
-
`Service "${name}" defines duplicate requirements.suites selector "${parsedSelector.raw}"`
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
const matched = discoveredSuites.some((suite) =>
|
|
633
|
-
matchesSuiteSelectors(suite.displayType, suite.name, [parsedSelector])
|
|
634
|
-
);
|
|
635
|
-
if (!matched) {
|
|
636
|
-
throw new Error(
|
|
637
|
-
`Service "${name}" requirements.suites selector "${parsedSelector.raw}" did not match any discovered suite`
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
seenSelectors.add(parsedSelector.raw);
|
|
641
|
-
suiteRules.push({
|
|
642
|
-
selector: parsedSelector,
|
|
643
|
-
locks: normalizeRequirementLocks(
|
|
644
|
-
rule.locks,
|
|
645
|
-
`Service "${name}" requirements.suites["${parsedSelector.raw}"].locks`
|
|
646
|
-
),
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const fileRules = [];
|
|
651
|
-
const seenFiles = new Set();
|
|
652
|
-
for (const [index, rule] of (value.files || []).entries()) {
|
|
653
|
-
if (!rule || typeof rule !== "object") {
|
|
654
|
-
throw new Error(`Service "${name}" requirements.files entries must be objects`);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
const filePath = String(rule.path || "").trim();
|
|
658
|
-
if (!filePath) {
|
|
659
|
-
throw new Error(`Service "${name}" requirements.files[${index}] requires a path`);
|
|
660
|
-
}
|
|
661
|
-
if (!discoveredFiles.has(filePath)) {
|
|
662
|
-
throw new Error(
|
|
663
|
-
`Service "${name}" requirements.files["${filePath}"] did not match any discovered test file`
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
if (seenFiles.has(filePath)) {
|
|
667
|
-
throw new Error(`Service "${name}" defines duplicate requirements.files path "${filePath}"`);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
seenFiles.add(filePath);
|
|
671
|
-
fileRules.push({
|
|
672
|
-
path: filePath,
|
|
673
|
-
locks: normalizeRequirementLocks(
|
|
674
|
-
rule.locks,
|
|
675
|
-
`Service "${name}" requirements.files["${filePath}"].locks`
|
|
676
|
-
),
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
suites: suiteRules,
|
|
682
|
-
files: fileRules,
|
|
683
|
-
fileLocksByPath: new Map(fileRules.map((rule) => [rule.path, rule.locks])),
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
function normalizeRequirementLocks(value, label) {
|
|
688
|
-
const input = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
689
|
-
const seen = new Set();
|
|
690
|
-
const locks = [];
|
|
691
|
-
|
|
692
|
-
for (const rawLock of input) {
|
|
693
|
-
const lockName = String(rawLock || "").trim();
|
|
694
|
-
if (!lockName) {
|
|
695
|
-
throw new Error(`${label} entries must be non-empty strings`);
|
|
696
|
-
}
|
|
697
|
-
if (seen.has(lockName)) continue;
|
|
698
|
-
seen.add(lockName);
|
|
699
|
-
locks.push(lockName);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
return locks.sort();
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function inferEnvFiles(productDir, explicitService, local) {
|
|
706
|
-
if (explicitService.envFile || explicitService.envFiles) {
|
|
707
|
-
const files = [];
|
|
708
|
-
if (explicitService.envFile) files.push(explicitService.envFile);
|
|
709
|
-
if (Array.isArray(explicitService.envFiles)) files.push(...explicitService.envFiles);
|
|
710
|
-
return files;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
const candidates = [];
|
|
714
|
-
const serviceCwd = local?.cwd || ".";
|
|
715
|
-
if (serviceCwd !== ".") {
|
|
716
|
-
candidates.push(path.posix.join(serviceCwd, ".env.testkit"));
|
|
717
|
-
candidates.push(path.posix.join(serviceCwd, ".env"));
|
|
718
|
-
}
|
|
719
|
-
candidates.push(".env.testkit");
|
|
720
|
-
candidates.push(".env");
|
|
721
|
-
|
|
722
|
-
return [...new Set(candidates)]
|
|
723
|
-
.map((candidate) => candidate.split(path.sep).join("/"))
|
|
724
|
-
.filter((candidate) => fs.existsSync(resolveServiceCwd(productDir, candidate)));
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
function normalizeSkipReason(reason, label) {
|
|
728
|
-
const normalized = String(reason || "").trim();
|
|
729
|
-
if (!normalized) {
|
|
730
|
-
throw new Error(`${label} requires a non-empty reason`);
|
|
731
|
-
}
|
|
732
|
-
return normalized;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function normalizeBrowserServiceConfig(value, serviceName) {
|
|
736
|
-
if (!value) return undefined;
|
|
737
|
-
if (typeof value !== "object" || Array.isArray(value)) {
|
|
738
|
-
throw new Error(`Service "${serviceName}" browser config must be an object`);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const origins = Array.isArray(value.origins)
|
|
742
|
-
? value.origins
|
|
743
|
-
.map((origin) => normalizeOptionalString(origin))
|
|
744
|
-
.filter(Boolean)
|
|
745
|
-
: [];
|
|
746
|
-
|
|
747
|
-
for (const origin of origins) {
|
|
748
|
-
try {
|
|
749
|
-
const parsed = new URL(origin);
|
|
750
|
-
if (!parsed.origin) {
|
|
751
|
-
throw new Error("missing origin");
|
|
752
|
-
}
|
|
753
|
-
} catch {
|
|
754
|
-
throw new Error(`Service "${serviceName}" browser.origins contains an invalid URL: ${origin}`);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
if (origins.length === 0) return undefined;
|
|
759
|
-
return { origins };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function loadServiceEnv(productDir, envFiles) {
|
|
763
|
-
const env = {};
|
|
764
|
-
for (const envFile of envFiles) {
|
|
765
|
-
Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
|
|
766
|
-
}
|
|
767
|
-
return env;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function validateConfigCoverage(configs) {
|
|
771
|
-
const names = new Set(configs.map((config) => config.name));
|
|
772
|
-
for (const config of configs) {
|
|
773
|
-
for (const depName of config.testkit.dependsOn || []) {
|
|
774
|
-
if (!names.has(depName)) {
|
|
775
|
-
throw new Error(`Service "${config.name}" depends on "${depName}", but ${depName} is not defined`);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
const databaseFrom = config.testkit.databaseFrom;
|
|
779
|
-
if (databaseFrom && !names.has(databaseFrom)) {
|
|
780
|
-
throw new Error(
|
|
781
|
-
`Service "${config.name}" databaseFrom "${databaseFrom}" is not defined`
|
|
782
|
-
);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function validateServiceConfig({
|
|
788
|
-
name,
|
|
789
|
-
local,
|
|
790
|
-
database,
|
|
791
|
-
databaseFrom,
|
|
792
|
-
runtime,
|
|
793
|
-
dependsOn,
|
|
794
|
-
suites,
|
|
795
|
-
productDir,
|
|
796
|
-
}) {
|
|
797
|
-
const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
|
|
798
|
-
discoveredSuites.some(
|
|
799
|
-
(suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
|
|
800
|
-
)
|
|
801
|
-
);
|
|
802
|
-
|
|
803
|
-
if (usesLocalExecution && !local) {
|
|
804
|
-
throw new Error(
|
|
805
|
-
`Service "${name}" defines non-DAL suites but no local runtime could be resolved. Add it in testkit.setup.ts.`
|
|
806
|
-
);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
if (database && databaseFrom) {
|
|
810
|
-
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
811
|
-
}
|
|
812
|
-
if (runtime.instances < 1) {
|
|
813
|
-
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
814
|
-
}
|
|
815
|
-
if (runtime.maxConcurrentTasks <= 0) {
|
|
816
|
-
throw new Error(
|
|
817
|
-
`Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
for (const depName of dependsOn || []) {
|
|
822
|
-
if (depName === name) {
|
|
823
|
-
throw new Error(`Service "${name}" cannot depend on itself`);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
if (local?.cwd) {
|
|
828
|
-
ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
|
|
829
|
-
}
|
|
830
|
-
if (runtime.toolchain?.cwd) {
|
|
831
|
-
ensureExistingPath(productDir, runtime.toolchain.cwd, `Service "${name}" runtime.toolchain.cwd`);
|
|
832
|
-
}
|
|
833
|
-
for (const [stageName, steps] of Object.entries(database?.template || {})) {
|
|
834
|
-
if (stageName === "inputs") continue;
|
|
835
|
-
for (const step of steps || []) {
|
|
836
|
-
if (step.cwd) {
|
|
837
|
-
ensureExistingPath(
|
|
838
|
-
productDir,
|
|
839
|
-
step.cwd,
|
|
840
|
-
`Service "${name}" database.template.${stageName} step cwd`
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
if (step.kind === "sql-file") {
|
|
844
|
-
ensureExistingPath(
|
|
845
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
846
|
-
step.path,
|
|
847
|
-
`Service "${name}" database.template.${stageName} sql file`
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
if (step.kind === "module") {
|
|
851
|
-
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
852
|
-
ensureExistingPath(
|
|
853
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
854
|
-
modulePath,
|
|
855
|
-
`Service "${name}" database.template.${stageName} module`
|
|
856
|
-
);
|
|
857
|
-
}
|
|
858
|
-
for (const input of step.inputs || []) {
|
|
859
|
-
ensureExistingPath(
|
|
860
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
861
|
-
input,
|
|
862
|
-
`Service "${name}" database.template.${stageName} step input`
|
|
863
|
-
);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
for (const input of database?.template?.inputs || []) {
|
|
868
|
-
ensureExistingPath(productDir, input, `Service "${name}" database.template input`);
|
|
869
|
-
}
|
|
870
|
-
for (const step of runtime.prepare?.steps || []) {
|
|
871
|
-
if (step.cwd) {
|
|
872
|
-
ensureExistingPath(productDir, step.cwd, `Service "${name}" runtime.prepare step cwd`);
|
|
873
|
-
}
|
|
874
|
-
if (step.kind === "sql-file") {
|
|
875
|
-
ensureExistingPath(
|
|
876
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
877
|
-
step.path,
|
|
878
|
-
`Service "${name}" runtime.prepare sql file`
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
if (step.kind === "module") {
|
|
882
|
-
const { modulePath } = parseModuleSpecifier(step.specifier);
|
|
883
|
-
ensureExistingPath(
|
|
884
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
885
|
-
modulePath,
|
|
886
|
-
`Service "${name}" runtime.prepare module`
|
|
887
|
-
);
|
|
888
|
-
}
|
|
889
|
-
for (const input of step.inputs || []) {
|
|
890
|
-
ensureExistingPath(
|
|
891
|
-
resolveServiceCwd(productDir, step.cwd || "."),
|
|
892
|
-
input,
|
|
893
|
-
`Service "${name}" runtime.prepare step input`
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
for (const input of runtime.prepare?.inputs || []) {
|
|
898
|
-
ensureExistingPath(productDir, input, `Service "${name}" runtime.prepare input`);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
function ensureExistingPath(productDir, relativePath, label) {
|
|
903
|
-
const absolute = resolveServiceCwd(productDir, relativePath);
|
|
904
|
-
if (!fs.existsSync(absolute)) {
|
|
905
|
-
throw new Error(`${label} does not exist: ${relativePath}`);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function normalizeTelemetryConfig(telemetry) {
|
|
910
|
-
if (!telemetry) return null;
|
|
911
|
-
if (telemetry.endpoint) {
|
|
912
|
-
let parsed;
|
|
913
|
-
try {
|
|
914
|
-
parsed = new URL(telemetry.endpoint);
|
|
915
|
-
} catch {
|
|
916
|
-
throw new Error("testkit.setup telemetry.endpoint must be a valid URL");
|
|
917
|
-
}
|
|
918
|
-
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
919
|
-
throw new Error("testkit.setup telemetry.endpoint must use http or https");
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
if (telemetry.enabled === true) {
|
|
923
|
-
if (!telemetry.endpoint) {
|
|
924
|
-
throw new Error("testkit.setup telemetry.endpoint is required when telemetry.enabled is true");
|
|
925
|
-
}
|
|
926
|
-
if (!telemetry.tokenEnv) {
|
|
927
|
-
throw new Error("testkit.setup telemetry.tokenEnv is required when telemetry.enabled is true");
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return {
|
|
931
|
-
enabled: telemetry.enabled === true,
|
|
932
|
-
endpoint: telemetry.endpoint,
|
|
933
|
-
tokenEnv: telemetry.tokenEnv,
|
|
934
|
-
timeoutMs: telemetry.timeoutMs || 3000,
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
function normalizeRepoExecution(execution) {
|
|
939
|
-
if (!execution) {
|
|
940
|
-
return normalizeExecutionConfig({
|
|
941
|
-
workers: 1,
|
|
942
|
-
fileTimeoutSeconds: DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
return normalizeExecutionConfig(execution);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
function normalizePath(value) {
|
|
949
|
-
return String(value || "")
|
|
950
|
-
.split(path.sep)
|
|
951
|
-
.join("/")
|
|
952
|
-
.replace(/^\.\/+/, "");
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
export function resolveProductDir(cwd, explicitDir) {
|
|
956
|
-
const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
|
|
957
|
-
if (!fs.existsSync(dir)) {
|
|
958
|
-
throw new Error(`Product directory does not exist: ${dir}`);
|
|
959
|
-
}
|
|
960
|
-
return dir;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
function parseDotenvString(source) {
|
|
964
|
-
const env = {};
|
|
965
|
-
for (const line of String(source).split("\n")) {
|
|
966
|
-
const trimmed = line.trim();
|
|
967
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
968
|
-
const eq = trimmed.indexOf("=");
|
|
969
|
-
if (eq === -1) continue;
|
|
970
|
-
const key = trimmed.slice(0, eq).trim();
|
|
971
|
-
let val = trimmed.slice(eq + 1).trim();
|
|
972
|
-
if (
|
|
973
|
-
(val.startsWith("'") && val.endsWith("'")) ||
|
|
974
|
-
(val.startsWith("\"") && val.endsWith("\""))
|
|
975
|
-
) {
|
|
976
|
-
val = val.slice(1, -1);
|
|
977
|
-
}
|
|
978
|
-
env[key] = val;
|
|
979
|
-
}
|
|
980
|
-
return env;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function detectNextApp(cwd) {
|
|
984
|
-
return (
|
|
985
|
-
fs.existsSync(path.join(cwd, "next.config.js")) ||
|
|
986
|
-
fs.existsSync(path.join(cwd, "next.config.mjs")) ||
|
|
987
|
-
fs.existsSync(path.join(cwd, "next.config.ts"))
|
|
988
|
-
);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function parseModuleSpecifier(specifier) {
|
|
992
|
-
const [modulePath, exportName] = String(specifier).split("#", 2);
|
|
993
|
-
return {
|
|
994
|
-
modulePath,
|
|
995
|
-
exportName: exportName || "default",
|
|
996
|
-
};
|
|
997
|
-
}
|