@elench/testkit 0.1.17 → 0.1.19

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 (53) hide show
  1. package/README.md +76 -16
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/bundler/index.mjs +95 -0
  4. package/lib/bundler/index.test.mjs +79 -0
  5. package/lib/cli/args.mjs +57 -0
  6. package/lib/cli/args.test.mjs +62 -0
  7. package/lib/cli/index.mjs +114 -0
  8. package/lib/config/index.mjs +294 -0
  9. package/lib/config/index.test.mjs +12 -0
  10. package/lib/config/model.mjs +422 -0
  11. package/lib/config/model.test.mjs +193 -0
  12. package/lib/database/fingerprint.mjs +61 -0
  13. package/lib/database/fingerprint.test.mjs +93 -0
  14. package/lib/{database.mjs → database/index.mjs} +45 -160
  15. package/lib/database/naming.mjs +47 -0
  16. package/lib/database/naming.test.mjs +39 -0
  17. package/lib/database/state.mjs +52 -0
  18. package/lib/database/state.test.mjs +66 -0
  19. package/lib/index.mjs +1 -0
  20. package/lib/k6/checks.mjs +1 -0
  21. package/lib/k6/dal-suite.mjs +1 -0
  22. package/lib/k6/dal.mjs +1 -0
  23. package/lib/k6/http.mjs +1 -0
  24. package/lib/k6/index.mjs +30 -0
  25. package/lib/k6/suite.mjs +1 -0
  26. package/lib/reporters/playwright.mjs +125 -0
  27. package/lib/reporters/playwright.test.mjs +73 -0
  28. package/lib/{runner.mjs → runner/index.mjs} +252 -835
  29. package/lib/runner/metadata.mjs +55 -0
  30. package/lib/runner/metadata.test.mjs +52 -0
  31. package/lib/runner/planning.mjs +270 -0
  32. package/lib/runner/planning.test.mjs +127 -0
  33. package/lib/runner/results.mjs +285 -0
  34. package/lib/runner/results.test.mjs +144 -0
  35. package/lib/runner/state.mjs +71 -0
  36. package/lib/runner/state.test.mjs +64 -0
  37. package/lib/runner/template.mjs +320 -0
  38. package/lib/runner/template.test.mjs +150 -0
  39. package/lib/runtime/index.mjs +191 -0
  40. package/lib/runtime-src/k6/checks.js +39 -0
  41. package/lib/runtime-src/k6/dal-suite.js +33 -0
  42. package/lib/runtime-src/k6/dal.js +32 -0
  43. package/lib/runtime-src/k6/http.js +134 -0
  44. package/lib/runtime-src/k6/suite.js +55 -0
  45. package/lib/telemetry/index.mjs +43 -0
  46. package/lib/timing/index.mjs +73 -0
  47. package/lib/timing/index.test.mjs +64 -0
  48. package/package.json +18 -3
  49. package/infra/neon-down.sh +0 -18
  50. package/infra/neon-up.sh +0 -124
  51. package/lib/cli.mjs +0 -132
  52. package/lib/config.mjs +0 -666
  53. package/lib/exec.mjs +0 -20
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,74 @@ 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
+ ## K6 Authoring
50
+
51
+ Consumer k6 tests can import the shared authoring API directly from the package:
52
+
53
+ ```js
54
+ import { defineHttpSuite } from "@elench/testkit";
55
+
56
+ const suite = defineHttpSuite(({ rawReq }) => {
57
+ const res = rawReq("GET", "/health");
58
+ });
59
+
60
+ export const options = suite.options;
61
+ export const setup = suite.setup;
62
+ export default suite.exec;
63
+ ```
64
+
65
+ For auth or schema-specific behavior, keep a small consumer-owned adapter next
66
+ to the tests and pass it into the generic suite factory:
67
+
68
+ ```js
69
+ import { defineHttpSuite } from "@elench/testkit";
70
+ import { clerkSessionAuth } from "../helpers/testkit-auth.js";
71
+
72
+ const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) => {
73
+ req("GET", "/api/auth/me", setupData);
74
+ });
75
+ ```
76
+
77
+ `testkit` bundles these imports before invoking k6, so tests do not need
78
+ generated `_testkit` files or direct package-manager path imports.
79
+
80
+ Legacy compatibility:
81
+
82
+ - `testkit runtime install`
83
+ - `testkit runtime status`
84
+ - `testkit runtime update`
85
+
86
+ still exist, but direct package imports are now the preferred model.
87
+
88
+ From outside the product repo, use `--dir` explicitly:
89
+
90
+ ```bash
91
+ npx @elench/testkit --dir my-product int
92
+ npx @elench/testkit --dir my-product api int -s health
93
+ ```
94
+
53
95
  ## How it works
54
96
 
55
97
  1. **Discovery** — reads `runner.manifest.json` for services, suites, files, and frameworks
56
98
  2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
57
99
  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
100
+ 3. **Database** — provisions Docker-managed local Postgres when a service declares one
59
101
  4. **Seed** — runs optional product seed commands against the provisioned database
60
102
  5. **Runtime** — starts required local services, waits for readiness, and injects test env
61
- 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
103
+ 6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles `k6` files before execution so package imports resolve cleanly, and batches Playwright files per worker
62
104
  7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
63
105
 
64
106
  Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
65
107
 
108
+ `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.
109
+
66
110
  ## File roles
67
111
 
68
112
  - `runner.manifest.json`: canonical test inventory
@@ -70,11 +114,12 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
70
114
 
71
115
  `testkit.config.json` can also declare:
72
116
 
117
+ - `telemetry` for optional generic HTTP result upload
73
118
  - `envFile` / `envFiles` for service-specific environment loading
74
119
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
75
- - `database.defaultBackend` / `database.backends` to switch between Neon and local Postgres
120
+ - `database.provider` for local Postgres settings
76
121
  - `database.template.inputs` to define the local template cache invalidation inputs
77
- - `migrate.backends` / `seed.backends` for backend-specific command overrides
122
+ - `migrate.backends` / `seed.backends` for optional local-only command overrides
78
123
 
79
124
  ## Parallel execution
80
125
 
@@ -83,12 +128,12 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
83
128
  `--jobs` is global for the whole run, not per service.
84
129
 
85
130
  Each worker gets its own:
86
- - Neon branch
87
- - or cloned local Postgres database
131
+
132
+ - cloned local Postgres database
88
133
  - `.testkit` state subtree
89
134
  - local service ports
90
135
 
91
- Workers prefer to stay on the same dependency graph so service stacks can be reused across assigned work, including dependent services such as `frontend -> bourne`.
136
+ 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`.
92
137
 
93
138
  Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
94
139
 
@@ -115,3 +160,18 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
115
160
  ## Schema
116
161
 
117
162
  See [testkit-config-schema.md](testkit-config-schema.md).
163
+
164
+ ## Development Tests
165
+
166
+ ```bash
167
+ npm test
168
+ npm run test:unit
169
+ npm run test:integration
170
+ npm run test:system
171
+ ```
172
+
173
+ `test:system` runs real end-to-end fixtures from `test/fixtures/system/`. It requires:
174
+
175
+ - Docker with a running daemon
176
+ - `k6` on `PATH`
177
+ - 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,95 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import crypto from "crypto";
5
+ import { build } from "esbuild";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
+ const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
10
+ const K6_ENTRY = path.join(PACKAGE_ROOT, "lib", "k6", "index.mjs");
11
+ const K6_DIR = path.join(PACKAGE_ROOT, "lib", "k6");
12
+ const bundleCache = new Map();
13
+
14
+ export async function bundleK6File({
15
+ productDir,
16
+ serviceName,
17
+ sourceFile,
18
+ }) {
19
+ const absoluteSource = path.resolve(productDir, sourceFile);
20
+ const bundleDir = path.join(productDir, ".testkit", "_bundles", serviceName || "shared");
21
+ fs.mkdirSync(bundleDir, { recursive: true });
22
+
23
+ const cacheKey = await buildCacheKey(absoluteSource);
24
+ const cached = bundleCache.get(cacheKey);
25
+ if (cached && fs.existsSync(cached)) {
26
+ return cached;
27
+ }
28
+
29
+ const outputFile = path.join(
30
+ bundleDir,
31
+ `${path.basename(sourceFile, path.extname(sourceFile))}-${cacheKey.slice(0, 12)}.js`
32
+ );
33
+
34
+ await build({
35
+ absWorkingDir: path.dirname(absoluteSource),
36
+ bundle: true,
37
+ entryPoints: [absoluteSource],
38
+ format: "esm",
39
+ legalComments: "none",
40
+ outfile: outputFile,
41
+ platform: "neutral",
42
+ sourcemap: "inline",
43
+ target: "es2020",
44
+ plugins: [testkitPackageAliasPlugin()],
45
+ external: [
46
+ "k6",
47
+ "k6/*",
48
+ ],
49
+ });
50
+
51
+ bundleCache.set(cacheKey, outputFile);
52
+ return outputFile;
53
+ }
54
+
55
+ async function buildCacheKey(sourceFile) {
56
+ const source = await fs.promises.readFile(sourceFile, "utf8");
57
+ const packageJson = await fs.promises.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8");
58
+ return crypto
59
+ .createHash("sha256")
60
+ .update(sourceFile)
61
+ .update("\0")
62
+ .update(source)
63
+ .update("\0")
64
+ .update(packageJson)
65
+ .digest("hex");
66
+ }
67
+
68
+ function testkitPackageAliasPlugin() {
69
+ return {
70
+ name: "testkit-package-alias",
71
+ setup(buildApi) {
72
+ buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => {
73
+ return {
74
+ namespace: "file",
75
+ path: resolvePackageSubpath(args.path),
76
+ };
77
+ });
78
+ },
79
+ };
80
+ }
81
+
82
+ function resolvePackageSubpath(specifier) {
83
+ const subpath = specifier.slice("@elench/testkit".length);
84
+ if (!subpath) return ROOT_ENTRY;
85
+ if (subpath === "/k6") return K6_ENTRY;
86
+ if (subpath.startsWith("/k6/")) {
87
+ const rel = subpath.slice("/k6/".length);
88
+ const candidate = path.join(K6_DIR, `${rel}.mjs`);
89
+ if (fs.existsSync(candidate)) {
90
+ return candidate;
91
+ }
92
+ }
93
+
94
+ throw new Error(`Unsupported @elench/testkit import "${specifier}" in ${os.platform()}`);
95
+ }
@@ -0,0 +1,79 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { bundleK6File } from "./index.mjs";
6
+
7
+ const cleanups = [];
8
+
9
+ afterEach(() => {
10
+ while (cleanups.length > 0) {
11
+ cleanups.pop()();
12
+ }
13
+ });
14
+
15
+ describe("k6 bundler", () => {
16
+ it("bundles root package imports for k6 execution", async () => {
17
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
18
+ cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
19
+
20
+ const sourceFile = path.join(tmpDir, "health.js");
21
+ fs.writeFileSync(
22
+ sourceFile,
23
+ [
24
+ 'import { defineHttpSuite, json } from "@elench/testkit";',
25
+ 'import { check } from "k6";',
26
+ "const suite = defineHttpSuite(({ rawReq }) => {",
27
+ ' const res = rawReq("GET", "/health");',
28
+ " check(json(res), {",
29
+ ' "has status": (body) => typeof body.status === "string",',
30
+ " });",
31
+ "});",
32
+ "export const options = suite.options;",
33
+ "export const setup = suite.setup;",
34
+ "export default suite.exec;",
35
+ "",
36
+ ].join("\n")
37
+ );
38
+
39
+ const bundledFile = await bundleK6File({
40
+ productDir: tmpDir,
41
+ serviceName: "api",
42
+ sourceFile,
43
+ });
44
+
45
+ const bundled = fs.readFileSync(bundledFile, "utf8");
46
+ expect(bundled).toContain("defineHttpSuite");
47
+ expect(bundled).toContain('import { check } from "k6"');
48
+ });
49
+
50
+ it("bundles subpath package imports for DAL execution", async () => {
51
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
52
+ cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
53
+
54
+ const sourceFile = path.join(tmpDir, "dal.js");
55
+ fs.writeFileSync(
56
+ sourceFile,
57
+ [
58
+ 'import { defineDalSuite } from "@elench/testkit/k6";',
59
+ "const suite = defineDalSuite(({ db }) => {",
60
+ ' db.query("SELECT 1");',
61
+ "});",
62
+ "export const options = suite.options;",
63
+ "export const setup = suite.setup;",
64
+ "export default suite.exec;",
65
+ "",
66
+ ].join("\n")
67
+ );
68
+
69
+ const bundledFile = await bundleK6File({
70
+ productDir: tmpDir,
71
+ serviceName: "api",
72
+ sourceFile,
73
+ });
74
+
75
+ const bundled = fs.readFileSync(bundledFile, "utf8");
76
+ expect(bundled).toContain("defineDalSuite");
77
+ expect(bundled).toContain('import sql from "k6/x/sql"');
78
+ });
79
+ });
@@ -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,114 @@
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
+ import * as runtime from "../runtime/index.mjs";
12
+
13
+ export function run() {
14
+ const cli = cac("testkit");
15
+
16
+ cli
17
+ .command("runtime <action>", "Install or inspect the consumer runtime bundle")
18
+ .option("--dir <path>", "Explicit product directory")
19
+ .option("--path <path>", "Target runtime path relative to the product directory")
20
+ .option("--strict", "Exit non-zero when runtime status is missing or drifted")
21
+ .action((action, options) => {
22
+ if (!["install", "status", "update"].includes(action)) {
23
+ throw new Error('Unknown runtime action. Expected one of: install, status, update.');
24
+ }
25
+
26
+ if (action === "status") {
27
+ const status = runtime.getRuntimeStatus(options);
28
+ console.log(runtime.formatRuntimeStatus(status));
29
+ if (options.strict && status.status !== "installed") {
30
+ process.exitCode = 1;
31
+ }
32
+ return;
33
+ }
34
+
35
+ const result = runtime.installRuntime(options);
36
+ console.log(
37
+ `Installed testkit runtime to ${result.relativeRuntimeDir} (${result.files.length} file${result.files.length === 1 ? "" : "s"})`
38
+ );
39
+ });
40
+
41
+ cli
42
+ .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
43
+ .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
44
+ .option("--dir <path>", "Explicit product directory")
45
+ .option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
46
+ default: "1",
47
+ })
48
+ .option("--shard <i/n>", "Run only shard i of n at suite granularity")
49
+ .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
50
+ default: "all",
51
+ })
52
+ .action(async (first, second, third, options) => {
53
+ // Resolve: service filter, suite type, and --dir.
54
+ //
55
+ // From product dir:
56
+ // testkit → all services, all types
57
+ // testkit int -s health → all services, int, health
58
+ // testkit api int → one service, int
59
+ // testkit api → one service, all types
60
+ //
61
+ // From workspace root:
62
+ // testkit --dir my-product int → all services, int
63
+ // testkit --dir my-product api int → one service, int
64
+
65
+ // Now resolve service vs type from remaining args
66
+ const serviceNames = new Set(getServiceNames(options.dir));
67
+ const { service, type } = resolveCliSelection({
68
+ first,
69
+ second,
70
+ serviceNames,
71
+ });
72
+
73
+ const allConfigs = loadConfigs({ dir: options.dir });
74
+ const configs = service
75
+ ? allConfigs.filter((config) => config.name === service)
76
+ : allConfigs;
77
+ if (service && configs.length === 0) {
78
+ const available = allConfigs.map((config) => config.name).join(", ");
79
+ throw new Error(`Service "${service}" not found. Available: ${available}`);
80
+ }
81
+
82
+ // Lifecycle commands
83
+ if (type === "status" || type === "destroy") {
84
+ for (const config of configs) {
85
+ if (configs.length > 1) console.log(`\n── ${config.name} ──`);
86
+ if (type === "status") runner.showStatus(config);
87
+ else await runner.destroy(config);
88
+ }
89
+ return;
90
+ }
91
+
92
+ validateFrameworkOption(options.framework);
93
+
94
+ const jobs = parseJobsOption(options.jobs);
95
+ const shard = parseShardOption(options.shard);
96
+
97
+ const suiteType = type || "all";
98
+ const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
99
+ await runner.runAll(
100
+ configs,
101
+ suiteType,
102
+ suiteNames,
103
+ {
104
+ ...options,
105
+ jobs,
106
+ shard,
107
+ },
108
+ allConfigs
109
+ );
110
+ });
111
+
112
+ cli.help();
113
+ cli.parse();
114
+ }