@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
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
|
|
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.
|
|
12
|
+
For DAL suites, `@elench/testkit` ships its own `k6` SQL binary.
|
|
13
13
|
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
|
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** —
|
|
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.
|
|
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
|
|
83
|
+
- `migrate.backends` / `seed.backends` for optional local-only command overrides
|
|
78
84
|
|
|
79
85
|
## Parallel execution
|
|
80
86
|
|
|
81
|
-
`@elench/testkit` can run
|
|
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
|
-
|
|
85
|
-
-
|
|
92
|
+
|
|
93
|
+
- cloned local Postgres database
|
|
86
94
|
- `.testkit` state subtree
|
|
87
95
|
- local service ports
|
|
88
96
|
|
|
89
|
-
|
|
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
|
|
109
|
-
- `weight`: optional scheduling weight when
|
|
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
package/lib/cli/args.mjs
ADDED
|
@@ -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
|
+
});
|