@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.
- package/README.md +76 -16
- package/bin/testkit.mjs +1 -1
- package/lib/bundler/index.mjs +95 -0
- package/lib/bundler/index.test.mjs +79 -0
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +114 -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/index.mjs +1 -0
- package/lib/k6/checks.mjs +1 -0
- package/lib/k6/dal-suite.mjs +1 -0
- package/lib/k6/dal.mjs +1 -0
- package/lib/k6/http.mjs +1 -0
- package/lib/k6/index.mjs +30 -0
- package/lib/k6/suite.mjs +1 -0
- package/lib/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/{runner.mjs → runner/index.mjs} +252 -835
- 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/runtime/index.mjs +191 -0
- package/lib/runtime-src/k6/checks.js +39 -0
- package/lib/runtime-src/k6/dal-suite.js +33 -0
- package/lib/runtime-src/k6/dal.js +32 -0
- package/lib/runtime-src/k6/http.js +134 -0
- package/lib/runtime-src/k6/suite.js +55 -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 +18 -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/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,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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
87
|
-
-
|
|
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 ->
|
|
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
|
@@ -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
|
+
});
|
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,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
|
+
}
|