@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.
Files changed (39) hide show
  1. package/README.md +44 -19
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/cli/args.mjs +57 -0
  4. package/lib/cli/args.test.mjs +62 -0
  5. package/lib/cli/index.mjs +88 -0
  6. package/lib/config/index.mjs +294 -0
  7. package/lib/config/index.test.mjs +12 -0
  8. package/lib/config/model.mjs +422 -0
  9. package/lib/config/model.test.mjs +193 -0
  10. package/lib/database/fingerprint.mjs +61 -0
  11. package/lib/database/fingerprint.test.mjs +93 -0
  12. package/lib/{database.mjs → database/index.mjs} +45 -160
  13. package/lib/database/naming.mjs +47 -0
  14. package/lib/database/naming.test.mjs +39 -0
  15. package/lib/database/state.mjs +52 -0
  16. package/lib/database/state.test.mjs +66 -0
  17. package/lib/reporters/playwright.mjs +125 -0
  18. package/lib/reporters/playwright.test.mjs +73 -0
  19. package/lib/runner/index.mjs +1221 -0
  20. package/lib/runner/metadata.mjs +55 -0
  21. package/lib/runner/metadata.test.mjs +52 -0
  22. package/lib/runner/planning.mjs +270 -0
  23. package/lib/runner/planning.test.mjs +127 -0
  24. package/lib/runner/results.mjs +285 -0
  25. package/lib/runner/results.test.mjs +144 -0
  26. package/lib/runner/state.mjs +71 -0
  27. package/lib/runner/state.test.mjs +64 -0
  28. package/lib/runner/template.mjs +320 -0
  29. package/lib/runner/template.test.mjs +150 -0
  30. package/lib/telemetry/index.mjs +43 -0
  31. package/lib/timing/index.mjs +73 -0
  32. package/lib/timing/index.test.mjs +64 -0
  33. package/package.json +11 -3
  34. package/infra/neon-down.sh +0 -18
  35. package/infra/neon-up.sh +0 -124
  36. package/lib/cli.mjs +0 -132
  37. package/lib/config.mjs +0 -666
  38. package/lib/exec.mjs +0 -20
  39. package/lib/runner.mjs +0 -1165
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `runner.manifest.json` plus `testkit.config.json` from a product repo, starts the required local services, provisions the selected database backend, and runs manifest-defined suites across `k6` and Playwright.
3
+ CLI that reads `runner.manifest.json` plus `testkit.config.json` from a product repo, starts the required local services, provisions local Postgres isolation, and runs manifest-defined suites across `k6` and Playwright.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -9,14 +9,14 @@ sudo snap install k6
9
9
  sudo apt-get install -y jq
10
10
  ```
11
11
 
12
- For DAL suites, `@elench/testkit` ships its own `k6` SQL binary. For suites using a Neon branch, set `NEON_API_KEY` in the product `.env`, shell, or `.envrc`.
12
+ For DAL suites, `@elench/testkit` ships its own `k6` SQL binary.
13
13
 
14
- Local database mode uses Docker-managed `postgres:16` containers. `testkit` creates and reuses template databases automatically, then clones per-worker databases from those templates for fast reruns.
14
+ Database isolation uses Docker-managed local Postgres containers. `testkit` creates and reuses template databases automatically, then clones per-worker databases from those templates for fast reruns. The default image is `pgvector/pgvector:pg16`.
15
15
 
16
16
  ## Usage
17
17
 
18
18
  ```bash
19
- cd bourne
19
+ cd my-product
20
20
 
21
21
  # Run every testkit-managed suite
22
22
  npx @elench/testkit
@@ -26,10 +26,6 @@ npx @elench/testkit int
26
26
  npx @elench/testkit dal
27
27
  npx @elench/testkit e2e
28
28
 
29
- # Force a database backend
30
- npx @elench/testkit --db-backend local
31
- npx @elench/testkit --db-backend neon
32
-
33
29
  # Filter by framework
34
30
  npx @elench/testkit --framework playwright
35
31
  npx @elench/testkit --framework k6
@@ -43,26 +39,35 @@ npx @elench/testkit --jobs 2 --shard 2/3
43
39
 
44
40
  # Specific service / suite
45
41
  npx @elench/testkit frontend e2e -s auth
46
- npx @elench/testkit bourne int -s health
42
+ npx @elench/testkit api int -s health
47
43
 
48
44
  # Lifecycle
49
45
  npx @elench/testkit status
50
46
  npx @elench/testkit destroy
51
47
  ```
52
48
 
49
+ From outside the product repo, use `--dir` explicitly:
50
+
51
+ ```bash
52
+ npx @elench/testkit --dir my-product int
53
+ npx @elench/testkit --dir my-product api int -s health
54
+ ```
55
+
53
56
  ## How it works
54
57
 
55
58
  1. **Discovery** — reads `runner.manifest.json` for services, suites, files, and frameworks
56
59
  2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
57
60
  Per-service `.env` files declared in config are loaded when present.
58
- 3. **Database** — provisions the selected backend (`neon` or Docker-managed `local`) when a service declares one
61
+ 3. **Database** — provisions Docker-managed local Postgres when a service declares one
59
62
  4. **Seed** — runs optional product seed commands against the provisioned database
60
63
  5. **Runtime** — starts required local services, waits for readiness, and injects test env
61
- 6. **Execution** — distributes suites across isolated worker stacks, runs `k6` suites file-by-file, and runs Playwright suites suite-by-suite
64
+ 6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, runs `k6` file-by-file, and batches Playwright files per worker
62
65
  7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
63
66
 
64
67
  Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
65
68
 
69
+ `testkit` also writes `.testkit/results/latest.json` for every run. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload that artifact to a configured HTTP endpoint with bearer auth.
70
+
66
71
  ## File roles
67
72
 
68
73
  - `runner.manifest.json`: canonical test inventory
@@ -70,26 +75,31 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
70
75
 
71
76
  `testkit.config.json` can also declare:
72
77
 
78
+ - `telemetry` for optional generic HTTP result upload
73
79
  - `envFile` / `envFiles` for service-specific environment loading
74
80
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
75
- - `database.defaultBackend` / `database.backends` to switch between Neon and local Postgres
81
+ - `database.provider` for local Postgres settings
76
82
  - `database.template.inputs` to define the local template cache invalidation inputs
77
- - `migrate.backends` / `seed.backends` for backend-specific command overrides
83
+ - `migrate.backends` / `seed.backends` for optional local-only command overrides
78
84
 
79
85
  ## Parallel execution
80
86
 
81
- `@elench/testkit` can run suites in parallel with `--jobs <n>`.
87
+ `@elench/testkit` can run test work in parallel with `--jobs <n>`.
88
+
89
+ `--jobs` is global for the whole run, not per service.
82
90
 
83
91
  Each worker gets its own:
84
- - Neon branch
85
- - or cloned local Postgres database
92
+
93
+ - cloned local Postgres database
86
94
  - `.testkit` state subtree
87
95
  - local service ports
88
96
 
89
- This keeps suites isolated while still reusing one stack per worker across multiple assigned suites.
97
+ Workers prefer to stay on the same dependency graph so service stacks can be reused across assigned work, including dependent services such as `frontend -> api`.
90
98
 
91
99
  Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
92
100
 
101
+ `testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
102
+
93
103
  ## Suite metadata
94
104
 
95
105
  `runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
@@ -105,9 +115,24 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
105
115
  }
106
116
  ```
107
117
 
108
- - `maxFileConcurrency`: k6-only opt-in for running files within the suite concurrently
109
- - `weight`: optional scheduling weight when distributing suites across workers
118
+ - `maxFileConcurrency`: k6-only opt-in for batching multiple files from the same suite onto one worker
119
+ - `weight`: optional fallback scheduling weight when no file timing history exists yet
110
120
 
111
121
  ## Schema
112
122
 
113
123
  See [testkit-config-schema.md](testkit-config-schema.md).
124
+
125
+ ## Development Tests
126
+
127
+ ```bash
128
+ npm test
129
+ npm run test:unit
130
+ npm run test:integration
131
+ npm run test:system
132
+ ```
133
+
134
+ `test:system` runs real end-to-end fixtures from `test/fixtures/system/`. It requires:
135
+
136
+ - Docker with a running daemon
137
+ - `k6` on `PATH`
138
+ - Playwright Chromium browser assets available to `@playwright/test`
package/bin/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { run } from "../lib/cli.mjs";
2
+ import { run } from "../lib/cli/index.mjs";
3
3
  run();
@@ -0,0 +1,57 @@
1
+ export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
2
+ export const LIFECYCLE = new Set(["status", "destroy"]);
3
+ export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
4
+
5
+ export function resolveCliSelection({ first, second, serviceNames }) {
6
+ let service = null;
7
+ let type = null;
8
+
9
+ if (first && serviceNames.has(first)) {
10
+ service = first;
11
+ type = second || null;
12
+ } else if (first && RESERVED.has(first)) {
13
+ type = first;
14
+ } else if (first) {
15
+ throw new Error(
16
+ `Unknown argument "${first}". Expected a service name (${[...serviceNames].join(", ") || "none found"}) ` +
17
+ `or suite type (int, e2e, dal, all).`
18
+ );
19
+ }
20
+
21
+ return { service, type };
22
+ }
23
+
24
+ export function validateFrameworkOption(value) {
25
+ if (!["all", "k6", "playwright"].includes(value)) {
26
+ throw new Error(
27
+ `Unknown framework "${value}". Expected one of: all, k6, playwright.`
28
+ );
29
+ }
30
+ }
31
+
32
+ export function parseJobsOption(value) {
33
+ const jobs = Number.parseInt(String(value), 10);
34
+ if (!Number.isInteger(jobs) || jobs <= 0) {
35
+ throw new Error(`Invalid --jobs value "${value}". Expected a positive integer.`);
36
+ }
37
+ return jobs;
38
+ }
39
+
40
+ export function parseShardOption(value) {
41
+ if (!value) return null;
42
+
43
+ const match = String(value).match(/^(\d+)\/(\d+)$/);
44
+ if (!match) {
45
+ throw new Error(
46
+ `Invalid --shard value "${value}". Expected the form "i/n", e.g. 1/3.`
47
+ );
48
+ }
49
+
50
+ const index = Number.parseInt(match[1], 10);
51
+ const total = Number.parseInt(match[2], 10);
52
+ if (index <= 0 || total <= 0 || index > total) {
53
+ throw new Error(`Invalid --shard value "${value}". Expected 1 <= i <= n.`);
54
+ }
55
+
56
+ return { index, total };
57
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parseJobsOption,
4
+ parseShardOption,
5
+ resolveCliSelection,
6
+ validateFrameworkOption,
7
+ } from "./args.mjs";
8
+
9
+ describe("cli-args", () => {
10
+ it("resolves a service and suite type", () => {
11
+ expect(
12
+ resolveCliSelection({
13
+ first: "api",
14
+ second: "int",
15
+ serviceNames: new Set(["api", "frontend"]),
16
+ })
17
+ ).toEqual({
18
+ service: "api",
19
+ type: "int",
20
+ });
21
+ });
22
+
23
+ it("resolves a reserved suite type without a service", () => {
24
+ expect(
25
+ resolveCliSelection({
26
+ first: "e2e",
27
+ second: null,
28
+ serviceNames: new Set(["api"]),
29
+ })
30
+ ).toEqual({
31
+ service: null,
32
+ type: "e2e",
33
+ });
34
+ });
35
+
36
+ it("rejects unknown positional arguments", () => {
37
+ expect(() =>
38
+ resolveCliSelection({
39
+ first: "mystery",
40
+ second: null,
41
+ serviceNames: new Set(["api"]),
42
+ })
43
+ ).toThrow('Unknown argument "mystery"');
44
+ });
45
+
46
+ it("validates framework names", () => {
47
+ expect(() => validateFrameworkOption("playwright")).not.toThrow();
48
+ expect(() => validateFrameworkOption("jest")).toThrow("Unknown framework");
49
+ });
50
+
51
+ it("parses and validates jobs", () => {
52
+ expect(parseJobsOption("3")).toBe(3);
53
+ expect(() => parseJobsOption("0")).toThrow("Invalid --jobs value");
54
+ });
55
+
56
+ it("parses and validates shards", () => {
57
+ expect(parseShardOption("2/5")).toEqual({ index: 2, total: 5 });
58
+ expect(parseShardOption(null)).toBeNull();
59
+ expect(() => parseShardOption("2-of-5")).toThrow("Invalid --shard value");
60
+ expect(() => parseShardOption("3/2")).toThrow("Expected 1 <= i <= n");
61
+ });
62
+ });
@@ -0,0 +1,88 @@
1
+ import { cac } from "cac";
2
+ import { loadConfigs, getServiceNames } from "../config/index.mjs";
3
+ import {
4
+ parseJobsOption,
5
+ parseShardOption,
6
+ RESERVED,
7
+ resolveCliSelection,
8
+ validateFrameworkOption,
9
+ } from "./args.mjs";
10
+ import * as runner from "../runner/index.mjs";
11
+
12
+ export function run() {
13
+ const cli = cac("testkit");
14
+
15
+ cli
16
+ .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
17
+ .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
18
+ .option("--dir <path>", "Explicit product directory")
19
+ .option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
20
+ default: "1",
21
+ })
22
+ .option("--shard <i/n>", "Run only shard i of n at suite granularity")
23
+ .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
24
+ default: "all",
25
+ })
26
+ .action(async (first, second, third, options) => {
27
+ // Resolve: service filter, suite type, and --dir.
28
+ //
29
+ // From product dir:
30
+ // testkit → all services, all types
31
+ // testkit int -s health → all services, int, health
32
+ // testkit api int → one service, int
33
+ // testkit api → one service, all types
34
+ //
35
+ // From workspace root:
36
+ // testkit --dir my-product int → all services, int
37
+ // testkit --dir my-product api int → one service, int
38
+
39
+ // Now resolve service vs type from remaining args
40
+ const serviceNames = new Set(getServiceNames(options.dir));
41
+ const { service, type } = resolveCliSelection({
42
+ first,
43
+ second,
44
+ serviceNames,
45
+ });
46
+
47
+ const allConfigs = loadConfigs({ dir: options.dir });
48
+ const configs = service
49
+ ? allConfigs.filter((config) => config.name === service)
50
+ : allConfigs;
51
+ if (service && configs.length === 0) {
52
+ const available = allConfigs.map((config) => config.name).join(", ");
53
+ throw new Error(`Service "${service}" not found. Available: ${available}`);
54
+ }
55
+
56
+ // Lifecycle commands
57
+ if (type === "status" || type === "destroy") {
58
+ for (const config of configs) {
59
+ if (configs.length > 1) console.log(`\n── ${config.name} ──`);
60
+ if (type === "status") runner.showStatus(config);
61
+ else await runner.destroy(config);
62
+ }
63
+ return;
64
+ }
65
+
66
+ validateFrameworkOption(options.framework);
67
+
68
+ const jobs = parseJobsOption(options.jobs);
69
+ const shard = parseShardOption(options.shard);
70
+
71
+ const suiteType = type || "all";
72
+ const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
73
+ await runner.runAll(
74
+ configs,
75
+ suiteType,
76
+ suiteNames,
77
+ {
78
+ ...options,
79
+ jobs,
80
+ shard,
81
+ },
82
+ allConfigs
83
+ );
84
+ });
85
+
86
+ cli.help();
87
+ cli.parse();
88
+ }
@@ -0,0 +1,294 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import {
5
+ getServiceEnvFiles as getServiceEnvFilesModel,
6
+ isDalSuiteType as isDalSuiteTypeModel,
7
+ isObject as isObjectModel,
8
+ normalizeTelemetryConfig as normalizeTelemetryConfigModel,
9
+ normalizeTemplateConfig as normalizeTemplateConfigModel,
10
+ parseDotenvString,
11
+ requireString as requireStringModel,
12
+ resolveLifecycleConfig as resolveLifecycleConfigModel,
13
+ resolveSelectedDatabase as resolveSelectedDatabaseModel,
14
+ validateConfigCoverage as validateConfigCoverageModel,
15
+ validateDatabaseProviderConfig as validateDatabaseProviderConfigModel,
16
+ validateHttpUrl as validateHttpUrlModel,
17
+ validateLifecycleConfig as validateLifecycleConfigModel,
18
+ validateRunnerManifest,
19
+ validateServiceConfig as validateServiceConfigModel,
20
+ validateTelemetryConfig as validateTelemetryConfigModel,
21
+ validateTemplateConfig as validateTemplateConfigModel,
22
+ } from "./model.mjs";
23
+
24
+ const RUNNER_MANIFEST = "runner.manifest.json";
25
+ const TESTKIT_CONFIG = "testkit.config.json";
26
+ export function parseDotenv(filePath) {
27
+ if (!fs.existsSync(filePath)) return {};
28
+ return parseDotenvString(fs.readFileSync(filePath, "utf8"));
29
+ }
30
+
31
+ export function getServiceNames(cwd) {
32
+ const dir = cwd || process.cwd();
33
+ const runnerPath = path.join(dir, RUNNER_MANIFEST);
34
+ if (!fs.existsSync(runnerPath)) return [];
35
+ const runner = JSON.parse(fs.readFileSync(runnerPath, "utf8"));
36
+ if (!isObject(runner.services)) return [];
37
+ return Object.keys(runner.services);
38
+ }
39
+
40
+ export function loadConfigs(opts = {}) {
41
+ const productDir = resolveProductDir(process.cwd(), opts.dir);
42
+ const runner = loadRunnerManifest(productDir);
43
+ const config = loadTestkitConfig(productDir);
44
+ validateConfigCoverage(runner, config);
45
+
46
+ const serviceEntries = Object.entries(runner.services);
47
+ const filtered = opts.service
48
+ ? serviceEntries.filter(([name]) => name === opts.service)
49
+ : serviceEntries;
50
+
51
+ if (opts.service && filtered.length === 0) {
52
+ const available = serviceEntries.map(([name]) => name).join(", ");
53
+ throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
54
+ }
55
+
56
+ return filtered.map(([name, runnerService]) => {
57
+ const serviceConfig = config.services[name];
58
+ if (!serviceConfig) {
59
+ throw new Error(
60
+ `Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
61
+ );
62
+ }
63
+
64
+ const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
65
+ const serviceEnv = loadServiceEnv(productDir, serviceConfig);
66
+ const selectedBackend = resolvedDatabase?.selectedBackend;
67
+ const resolvedMigrate = resolveLifecycleConfig(serviceConfig.migrate, selectedBackend);
68
+ const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
69
+ validateMergedService(
70
+ name,
71
+ runnerService,
72
+ serviceConfig,
73
+ resolvedDatabase,
74
+ resolvedMigrate,
75
+ resolvedSeed,
76
+ productDir
77
+ );
78
+
79
+ return {
80
+ name,
81
+ productDir,
82
+ stateDir: path.join(productDir, ".testkit", name),
83
+ telemetry: normalizeTelemetryConfig(config.telemetry),
84
+ suites: runnerService.suites,
85
+ testkit: {
86
+ ...serviceConfig,
87
+ database: resolvedDatabase,
88
+ migrate: resolvedMigrate,
89
+ seed: resolvedSeed,
90
+ envFiles: getServiceEnvFiles(serviceConfig),
91
+ serviceEnv,
92
+ },
93
+ };
94
+ });
95
+ }
96
+
97
+ export function resolveDalBinary() {
98
+ const thisFile = fileURLToPath(import.meta.url);
99
+ const abs = path.resolve(path.dirname(thisFile), "..", "..", "vendor", "k6");
100
+ if (!fs.existsSync(abs)) {
101
+ throw new Error(`Bundled DAL k6 binary not found: ${abs}`);
102
+ }
103
+ return abs;
104
+ }
105
+
106
+ export function resolveServiceCwd(productDir, maybeRelative) {
107
+ return path.resolve(productDir, maybeRelative || ".");
108
+ }
109
+
110
+ function loadRunnerManifest(productDir) {
111
+ const manifestPath = path.join(productDir, RUNNER_MANIFEST);
112
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
113
+ validateRunnerManifest(raw, RUNNER_MANIFEST, manifestPath);
114
+ return raw;
115
+ }
116
+
117
+ function loadTestkitConfig(productDir) {
118
+ const configPath = path.join(productDir, TESTKIT_CONFIG);
119
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
120
+
121
+ if (!isObject(raw.services)) {
122
+ throw new Error(`${TESTKIT_CONFIG} must have a "services" object (${configPath})`);
123
+ }
124
+
125
+ if (raw.telemetry !== undefined) {
126
+ validateTelemetryConfigModel(raw.telemetry, configPath, TESTKIT_CONFIG);
127
+ }
128
+
129
+ for (const [serviceName, service] of Object.entries(raw.services)) {
130
+ validateServiceConfigModel(serviceName, service, configPath);
131
+ }
132
+
133
+ return raw;
134
+ }
135
+
136
+ function validateConfigCoverage(runner, config) {
137
+ return validateConfigCoverageModel(runner, config, TESTKIT_CONFIG, RUNNER_MANIFEST);
138
+ }
139
+
140
+ function resolveProductDir(cwd, explicitDir) {
141
+ if (explicitDir) {
142
+ const dir = path.resolve(cwd, explicitDir);
143
+ ensureProductFiles(dir);
144
+ return dir;
145
+ }
146
+
147
+ ensureProductFiles(cwd);
148
+ return cwd;
149
+ }
150
+
151
+ function ensureProductFiles(dir) {
152
+ const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
153
+ (file) => !fs.existsSync(path.join(dir, file))
154
+ );
155
+ if (missing.length > 0) {
156
+ throw new Error(
157
+ `Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
158
+ );
159
+ }
160
+ }
161
+
162
+ function validateMergedService(
163
+ name,
164
+ runnerService,
165
+ serviceConfig,
166
+ resolvedDatabase,
167
+ resolvedMigrate,
168
+ resolvedSeed,
169
+ productDir
170
+ ) {
171
+ const usesLocalExecution = Object.values(runnerService.suites).some((suites) =>
172
+ suites.some(
173
+ (suite) =>
174
+ (suite.framework && suite.framework !== "k6") ||
175
+ !isDalSuiteType(suite, runnerService, suites)
176
+ )
177
+ );
178
+
179
+ if (usesLocalExecution && !isObject(serviceConfig.local)) {
180
+ throw new Error(
181
+ `Service "${name}" defines non-DAL suites but has no local runtime in ${TESTKIT_CONFIG}`
182
+ );
183
+ }
184
+
185
+ if (serviceConfig.dependsOn) {
186
+ for (const dep of serviceConfig.dependsOn) {
187
+ if (dep === name) {
188
+ throw new Error(`Service "${name}" cannot depend on itself`);
189
+ }
190
+ }
191
+ }
192
+
193
+ if (
194
+ resolvedDatabase?.provider === "local" &&
195
+ (serviceConfig.migrate || serviceConfig.seed) &&
196
+ resolvedDatabase.template.inputs.length === 0
197
+ ) {
198
+ throw new Error(
199
+ `Service "${name}" uses local database provisioning with migrations or seeds, so database.template.inputs must list the files/directories that define the template cache`
200
+ );
201
+ }
202
+
203
+ if (serviceConfig.local?.cwd) {
204
+ const cwdPath = resolveServiceCwd(productDir, serviceConfig.local.cwd);
205
+ if (!fs.existsSync(cwdPath)) {
206
+ throw new Error(
207
+ `Service "${name}" local.cwd does not exist: ${serviceConfig.local.cwd}`
208
+ );
209
+ }
210
+ }
211
+
212
+ if (resolvedMigrate?.cwd) {
213
+ const cwdPath = resolveServiceCwd(productDir, resolvedMigrate.cwd);
214
+ if (!fs.existsSync(cwdPath)) {
215
+ throw new Error(
216
+ `Service "${name}" migrate.cwd does not exist: ${resolvedMigrate.cwd}`
217
+ );
218
+ }
219
+ }
220
+
221
+ if (resolvedSeed?.cwd) {
222
+ const cwdPath = resolveServiceCwd(productDir, resolvedSeed.cwd);
223
+ if (!fs.existsSync(cwdPath)) {
224
+ throw new Error(
225
+ `Service "${name}" seed.cwd does not exist: ${resolvedSeed.cwd}`
226
+ );
227
+ }
228
+ }
229
+
230
+ }
231
+
232
+ function validateServiceConfig(name, service, configPath) {
233
+ return validateServiceConfigModel(name, service, configPath);
234
+ }
235
+
236
+ function validateTelemetryConfig(telemetry, configPath) {
237
+ return validateTelemetryConfigModel(telemetry, configPath, TESTKIT_CONFIG);
238
+ }
239
+
240
+ function loadServiceEnv(productDir, serviceConfig) {
241
+ const env = {};
242
+ for (const envFile of getServiceEnvFiles(serviceConfig)) {
243
+ Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
244
+ }
245
+ return env;
246
+ }
247
+
248
+ function getServiceEnvFiles(serviceConfig) {
249
+ return getServiceEnvFilesModel(serviceConfig);
250
+ }
251
+
252
+ function resolveSelectedDatabase(name, serviceConfig) {
253
+ return resolveSelectedDatabaseModel(name, serviceConfig);
254
+ }
255
+
256
+ function validateDatabaseProviderConfig(name, db, label) {
257
+ return validateDatabaseProviderConfigModel(name, db, label);
258
+ }
259
+
260
+ function validateTemplateConfig(name, template, label) {
261
+ return validateTemplateConfigModel(name, template, label);
262
+ }
263
+
264
+ function normalizeTemplateConfig(template) {
265
+ return normalizeTemplateConfigModel(template);
266
+ }
267
+
268
+ function validateLifecycleConfig(name, value, label) {
269
+ return validateLifecycleConfigModel(name, value, label);
270
+ }
271
+
272
+ function resolveLifecycleConfig(value, selectedBackend) {
273
+ return resolveLifecycleConfigModel(value, selectedBackend);
274
+ }
275
+
276
+ function requireString(obj, key, label) {
277
+ return requireStringModel(obj, key, label);
278
+ }
279
+
280
+ function isDalSuiteType(suite, runnerService, suitesForType) {
281
+ return isDalSuiteTypeModel(suite, runnerService, suitesForType);
282
+ }
283
+
284
+ function isObject(value) {
285
+ return isObjectModel(value);
286
+ }
287
+
288
+ function normalizeTelemetryConfig(telemetry) {
289
+ return normalizeTelemetryConfigModel(telemetry);
290
+ }
291
+
292
+ function validateHttpUrl(value, label) {
293
+ return validateHttpUrlModel(value, label);
294
+ }
@@ -0,0 +1,12 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { resolveDalBinary } from "./index.mjs";
5
+
6
+ describe("config-index", () => {
7
+ it("resolves the bundled DAL k6 binary from the package root", () => {
8
+ const binaryPath = resolveDalBinary();
9
+ expect(path.basename(binaryPath)).toBe("k6");
10
+ expect(fs.existsSync(binaryPath)).toBe(true);
11
+ });
12
+ });