@elench/testkit 0.1.18 → 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 +40 -1
- package/lib/bundler/index.mjs +95 -0
- package/lib/bundler/index.test.mjs +79 -0
- package/lib/cli/index.mjs +26 -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/runner/index.mjs +13 -2
- 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/package.json +8 -1
package/README.md
CHANGED
|
@@ -46,6 +46,45 @@ npx @elench/testkit status
|
|
|
46
46
|
npx @elench/testkit destroy
|
|
47
47
|
```
|
|
48
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
|
+
|
|
49
88
|
From outside the product repo, use `--dir` explicitly:
|
|
50
89
|
|
|
51
90
|
```bash
|
|
@@ -61,7 +100,7 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
61
100
|
3. **Database** — provisions Docker-managed local Postgres when a service declares one
|
|
62
101
|
4. **Seed** — runs optional product seed commands against the provisioned database
|
|
63
102
|
5. **Runtime** — starts required local services, waits for readiness, and injects test env
|
|
64
|
-
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
|
|
65
104
|
7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
|
|
66
105
|
|
|
67
106
|
Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
|
|
@@ -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/index.mjs
CHANGED
|
@@ -8,10 +8,36 @@ import {
|
|
|
8
8
|
validateFrameworkOption,
|
|
9
9
|
} from "./args.mjs";
|
|
10
10
|
import * as runner from "../runner/index.mjs";
|
|
11
|
+
import * as runtime from "../runtime/index.mjs";
|
|
11
12
|
|
|
12
13
|
export function run() {
|
|
13
14
|
const cli = cac("testkit");
|
|
14
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
|
+
|
|
15
41
|
cli
|
|
16
42
|
.command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
|
|
17
43
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./k6/index.mjs";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../runtime-src/k6/checks.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../runtime-src/k6/dal-suite.js";
|
package/lib/k6/dal.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../runtime-src/k6/dal.js";
|
package/lib/k6/http.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../runtime-src/k6/http.js";
|
package/lib/k6/index.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export {
|
|
2
|
+
allMatch,
|
|
3
|
+
contains,
|
|
4
|
+
defaultOptions,
|
|
5
|
+
isSorted,
|
|
6
|
+
json,
|
|
7
|
+
singleIterationOptions,
|
|
8
|
+
} from "../runtime-src/k6/checks.js";
|
|
9
|
+
export {
|
|
10
|
+
createDalContext,
|
|
11
|
+
openDb,
|
|
12
|
+
truncate,
|
|
13
|
+
} from "../runtime-src/k6/dal.js";
|
|
14
|
+
export { defineDalSuite } from "../runtime-src/k6/dal-suite.js";
|
|
15
|
+
export {
|
|
16
|
+
createHttpClient,
|
|
17
|
+
defaultOptions as httpDefaultOptions,
|
|
18
|
+
getEnv,
|
|
19
|
+
makeGetWithHeaders,
|
|
20
|
+
makeRawReq,
|
|
21
|
+
makeReq,
|
|
22
|
+
} from "../runtime-src/k6/http.js";
|
|
23
|
+
export { defineHttpSuite } from "../runtime-src/k6/suite.js";
|
|
24
|
+
|
|
25
|
+
export function createAuthAdapter({ setup, headers } = {}) {
|
|
26
|
+
return {
|
|
27
|
+
setup,
|
|
28
|
+
headers,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/lib/k6/suite.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../runtime-src/k6/suite.js";
|
package/lib/runner/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import net from "net";
|
|
5
5
|
import { execa, execaCommand } from "execa";
|
|
6
|
+
import { bundleK6File } from "../bundler/index.mjs";
|
|
6
7
|
import { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
|
|
7
8
|
import {
|
|
8
9
|
cleanupOrphanedLocalInfrastructure,
|
|
@@ -520,10 +521,15 @@ async function runHttpK6Batch(targetConfig, batch) {
|
|
|
520
521
|
|
|
521
522
|
async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
522
523
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
524
|
+
const bundledFile = await bundleK6File({
|
|
525
|
+
productDir: targetConfig.productDir,
|
|
526
|
+
serviceName: targetConfig.name,
|
|
527
|
+
sourceFile: absFile,
|
|
528
|
+
});
|
|
523
529
|
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
524
530
|
const startedAt = Date.now();
|
|
525
531
|
try {
|
|
526
|
-
await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`,
|
|
532
|
+
await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
|
|
527
533
|
cwd: targetConfig.productDir,
|
|
528
534
|
env: buildExecutionEnv(targetConfig),
|
|
529
535
|
stdio: "inherit",
|
|
@@ -562,12 +568,17 @@ async function runDalBatch(targetConfig, batch) {
|
|
|
562
568
|
async function runDalTask(targetConfig, task, databaseUrl) {
|
|
563
569
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
564
570
|
const k6Binary = resolveDalBinary();
|
|
571
|
+
const bundledFile = await bundleK6File({
|
|
572
|
+
productDir: targetConfig.productDir,
|
|
573
|
+
serviceName: targetConfig.name,
|
|
574
|
+
sourceFile: absFile,
|
|
575
|
+
});
|
|
565
576
|
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
566
577
|
const startedAt = Date.now();
|
|
567
578
|
try {
|
|
568
579
|
await execa(
|
|
569
580
|
k6Binary,
|
|
570
|
-
["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`,
|
|
581
|
+
["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
|
|
571
582
|
{
|
|
572
583
|
cwd: targetConfig.productDir,
|
|
573
584
|
env: buildExecutionEnv(targetConfig),
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const RUNNER_MANIFEST = "runner.manifest.json";
|
|
7
|
+
const TESTKIT_CONFIG = "testkit.config.json";
|
|
8
|
+
const DEFAULT_RUNTIME_DIR = path.join("tests", "_testkit");
|
|
9
|
+
const METADATA_FILE = ".runtime-manifest.json";
|
|
10
|
+
const RUNTIME_FORMAT = 1;
|
|
11
|
+
|
|
12
|
+
export function installRuntime(options = {}) {
|
|
13
|
+
const productDir = resolveProductDir(process.cwd(), options.dir);
|
|
14
|
+
const runtimeDir = resolveRuntimeDir(productDir, options.path);
|
|
15
|
+
const sourceFiles = readBundledRuntimeFiles();
|
|
16
|
+
|
|
17
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
for (const file of sourceFiles) {
|
|
20
|
+
const targetPath = path.join(runtimeDir, file.path);
|
|
21
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(targetPath, file.content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const metadata = {
|
|
26
|
+
format: RUNTIME_FORMAT,
|
|
27
|
+
package: "@elench/testkit",
|
|
28
|
+
version: readPackageVersion(),
|
|
29
|
+
files: sourceFiles.map((file) => ({
|
|
30
|
+
path: file.path,
|
|
31
|
+
sha256: hashContent(file.content),
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
path.join(runtimeDir, METADATA_FILE),
|
|
36
|
+
`${JSON.stringify(metadata, null, 2)}\n`
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
productDir,
|
|
41
|
+
runtimeDir,
|
|
42
|
+
relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
|
|
43
|
+
files: metadata.files,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getRuntimeStatus(options = {}) {
|
|
48
|
+
const productDir = resolveProductDir(process.cwd(), options.dir);
|
|
49
|
+
const runtimeDir = resolveRuntimeDir(productDir, options.path);
|
|
50
|
+
const sourceFiles = readBundledRuntimeFiles();
|
|
51
|
+
const metadataPath = path.join(runtimeDir, METADATA_FILE);
|
|
52
|
+
|
|
53
|
+
if (!fs.existsSync(runtimeDir) || !fs.existsSync(metadataPath)) {
|
|
54
|
+
return {
|
|
55
|
+
status: "missing",
|
|
56
|
+
productDir,
|
|
57
|
+
runtimeDir,
|
|
58
|
+
relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
|
|
59
|
+
missingFiles: sourceFiles.map((file) => file.path),
|
|
60
|
+
driftedFiles: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const missingFiles = [];
|
|
65
|
+
const driftedFiles = [];
|
|
66
|
+
|
|
67
|
+
for (const file of sourceFiles) {
|
|
68
|
+
const targetPath = path.join(runtimeDir, file.path);
|
|
69
|
+
if (!fs.existsSync(targetPath)) {
|
|
70
|
+
missingFiles.push(file.path);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const installed = fs.readFileSync(targetPath, "utf8");
|
|
75
|
+
if (installed !== file.content) {
|
|
76
|
+
driftedFiles.push(file.path);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
81
|
+
const versionMatches = metadata.version === readPackageVersion();
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
status:
|
|
85
|
+
missingFiles.length === 0 && driftedFiles.length === 0 && versionMatches
|
|
86
|
+
? "installed"
|
|
87
|
+
: "drifted",
|
|
88
|
+
productDir,
|
|
89
|
+
runtimeDir,
|
|
90
|
+
relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
|
|
91
|
+
versionMatches,
|
|
92
|
+
missingFiles,
|
|
93
|
+
driftedFiles,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatRuntimeStatus(result) {
|
|
98
|
+
if (result.status === "missing") {
|
|
99
|
+
return `Runtime not installed at ${result.relativeRuntimeDir}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (result.status === "installed") {
|
|
103
|
+
return `Runtime at ${result.relativeRuntimeDir} is up to date`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const problems = [];
|
|
107
|
+
if (result.missingFiles.length > 0) {
|
|
108
|
+
problems.push(`missing: ${result.missingFiles.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
if (result.driftedFiles.length > 0) {
|
|
111
|
+
problems.push(`drifted: ${result.driftedFiles.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
if (result.versionMatches === false) {
|
|
114
|
+
problems.push("version drift");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return `Runtime at ${result.relativeRuntimeDir} is drifted (${problems.join("; ")})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveProductDir(cwd, explicitDir) {
|
|
121
|
+
const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
|
|
122
|
+
ensureProductFiles(dir);
|
|
123
|
+
return dir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureProductFiles(dir) {
|
|
127
|
+
const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
|
|
128
|
+
(file) => !fs.existsSync(path.join(dir, file))
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveRuntimeDir(productDir, explicitPath) {
|
|
139
|
+
return path.resolve(productDir, explicitPath || DEFAULT_RUNTIME_DIR);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function relativeToProduct(productDir, targetPath) {
|
|
143
|
+
return path.relative(productDir, targetPath) || ".";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readBundledRuntimeFiles() {
|
|
147
|
+
const sourceDir = path.resolve(
|
|
148
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
149
|
+
"..",
|
|
150
|
+
"runtime-src"
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return walkRuntimeFiles(sourceDir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function walkRuntimeFiles(rootDir, relativeDir = "") {
|
|
157
|
+
const entries = fs.readdirSync(path.join(rootDir, relativeDir), {
|
|
158
|
+
withFileTypes: true,
|
|
159
|
+
});
|
|
160
|
+
const files = [];
|
|
161
|
+
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const nextRelative = path.join(relativeDir, entry.name);
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
files.push(...walkRuntimeFiles(rootDir, nextRelative));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const absolute = path.join(rootDir, nextRelative);
|
|
170
|
+
files.push({
|
|
171
|
+
path: nextRelative.split(path.sep).join("/"),
|
|
172
|
+
content: fs.readFileSync(absolute, "utf8"),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readPackageVersion() {
|
|
180
|
+
const packagePath = path.resolve(
|
|
181
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
182
|
+
"..",
|
|
183
|
+
"..",
|
|
184
|
+
"package.json"
|
|
185
|
+
);
|
|
186
|
+
return JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hashContent(content) {
|
|
190
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
191
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function singleIterationOptions(overrides = {}) {
|
|
2
|
+
return {
|
|
3
|
+
iterations: 1,
|
|
4
|
+
thresholds: {
|
|
5
|
+
checks: ["rate==1.0"],
|
|
6
|
+
...(overrides.thresholds || {}),
|
|
7
|
+
},
|
|
8
|
+
...overrides,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const defaultOptions = singleIterationOptions();
|
|
13
|
+
|
|
14
|
+
export function json(res) {
|
|
15
|
+
return JSON.parse(res.body);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function contains(rows, field, value) {
|
|
19
|
+
return rows.some((row) => row[field] === value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function allMatch(rows, predicate) {
|
|
23
|
+
return rows.length > 0 && rows.every(predicate);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSorted(rows, field, direction = "asc") {
|
|
27
|
+
if (rows.length <= 1) return true;
|
|
28
|
+
|
|
29
|
+
for (let index = 1; index < rows.length; index += 1) {
|
|
30
|
+
if (direction === "asc" && rows[index][field] < rows[index - 1][field]) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (direction === "desc" && rows[index][field] > rows[index - 1][field]) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defaultOptions } from "./checks.js";
|
|
2
|
+
import { createDalContext, openDb } from "./dal.js";
|
|
3
|
+
|
|
4
|
+
export function defineDalSuite(configOrRun, maybeRun) {
|
|
5
|
+
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
6
|
+
const db = config.db || openDb();
|
|
7
|
+
const dal = createDalContext(db);
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
options: config.options || defaultOptions,
|
|
11
|
+
setup() {
|
|
12
|
+
if (typeof config.setup !== "function") return null;
|
|
13
|
+
return config.setup({ db, dal });
|
|
14
|
+
},
|
|
15
|
+
exec(setupData) {
|
|
16
|
+
return run({
|
|
17
|
+
db,
|
|
18
|
+
dal,
|
|
19
|
+
setupData,
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
26
|
+
if (typeof configOrRun === "function") {
|
|
27
|
+
return { config: {}, run: configOrRun };
|
|
28
|
+
}
|
|
29
|
+
if (typeof maybeRun !== "function") {
|
|
30
|
+
throw new Error("suite factory requires a run callback");
|
|
31
|
+
}
|
|
32
|
+
return { config: configOrRun || {}, run: maybeRun };
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import sql from "k6/x/sql";
|
|
2
|
+
import driver from "k6/x/sql/driver/postgres";
|
|
3
|
+
import {
|
|
4
|
+
allMatch,
|
|
5
|
+
contains,
|
|
6
|
+
defaultOptions,
|
|
7
|
+
isSorted,
|
|
8
|
+
} from "./checks.js";
|
|
9
|
+
|
|
10
|
+
export { allMatch, contains, defaultOptions, isSorted };
|
|
11
|
+
|
|
12
|
+
export function openDb() {
|
|
13
|
+
const url = __ENV.DATABASE_URL;
|
|
14
|
+
if (!url) {
|
|
15
|
+
throw new Error("DATABASE_URL env var required");
|
|
16
|
+
}
|
|
17
|
+
return sql.open(driver, url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function truncate(db, ...tables) {
|
|
21
|
+
if (tables.length === 0) return;
|
|
22
|
+
db.exec(`TRUNCATE ${tables.join(", ")} CASCADE`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createDalContext(db = openDb()) {
|
|
26
|
+
return {
|
|
27
|
+
db,
|
|
28
|
+
truncate(...tables) {
|
|
29
|
+
return truncate(db, ...tables);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import http from "k6/http";
|
|
2
|
+
import { defaultOptions } from "./checks.js";
|
|
3
|
+
|
|
4
|
+
export { defaultOptions };
|
|
5
|
+
|
|
6
|
+
export function getEnv() {
|
|
7
|
+
const BASE = __ENV.BASE_URL;
|
|
8
|
+
const MACHINE_ID = __ENV.MACHINE_ID;
|
|
9
|
+
|
|
10
|
+
if (!BASE) {
|
|
11
|
+
throw new Error("BASE_URL env var required");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const routeParams = MACHINE_ID ? { "fly-force-instance-id": MACHINE_ID } : {};
|
|
15
|
+
return { BASE, MACHINE_ID, routeParams };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createHttpClient(config) {
|
|
19
|
+
const {
|
|
20
|
+
baseUrl,
|
|
21
|
+
routeHeaders = {},
|
|
22
|
+
defaultHeaders = { "Content-Type": "application/json" },
|
|
23
|
+
getHeaders = null,
|
|
24
|
+
getRawHeaders = null,
|
|
25
|
+
} = config;
|
|
26
|
+
|
|
27
|
+
if (!baseUrl) {
|
|
28
|
+
throw new Error("baseUrl is required");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildHeaders(builder, setupData, extraHeaders = {}) {
|
|
32
|
+
return {
|
|
33
|
+
...defaultHeaders,
|
|
34
|
+
...safeHeaders(builder, setupData),
|
|
35
|
+
...routeHeaders,
|
|
36
|
+
...extraHeaders,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function request(method, path, setupData, body, extraHeaders = {}) {
|
|
41
|
+
const url = `${baseUrl}${path}`;
|
|
42
|
+
const headers = buildHeaders(getHeaders, setupData, extraHeaders);
|
|
43
|
+
return runHttpRequest(method, url, body, headers);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function raw(method, path, body, extraHeaders = {}) {
|
|
47
|
+
const url = `${baseUrl}${path}`;
|
|
48
|
+
const headers = buildHeaders(getRawHeaders, null, extraHeaders);
|
|
49
|
+
return runHttpRequest(method, url, body, headers);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getWithHeaders(path, setupData, extraHeaders = {}) {
|
|
53
|
+
return http.get(`${baseUrl}${path}`, {
|
|
54
|
+
headers: buildHeaders(getHeaders, setupData, extraHeaders),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
request,
|
|
60
|
+
raw,
|
|
61
|
+
get(path, setupData, extraHeaders = {}) {
|
|
62
|
+
return request("GET", path, setupData, null, extraHeaders);
|
|
63
|
+
},
|
|
64
|
+
put(path, setupData, body, extraHeaders = {}) {
|
|
65
|
+
return request("PUT", path, setupData, body, extraHeaders);
|
|
66
|
+
},
|
|
67
|
+
post(path, setupData, body, extraHeaders = {}) {
|
|
68
|
+
return request("POST", path, setupData, body, extraHeaders);
|
|
69
|
+
},
|
|
70
|
+
patch(path, setupData, body, extraHeaders = {}) {
|
|
71
|
+
return request("PATCH", path, setupData, body, extraHeaders);
|
|
72
|
+
},
|
|
73
|
+
delete(path, setupData, extraHeaders = {}) {
|
|
74
|
+
return request("DELETE", path, setupData, null, extraHeaders);
|
|
75
|
+
},
|
|
76
|
+
rawGet(path, extraHeaders = {}) {
|
|
77
|
+
return raw("GET", path, null, extraHeaders);
|
|
78
|
+
},
|
|
79
|
+
rawPost(path, body, extraHeaders = {}) {
|
|
80
|
+
return raw("POST", path, body, extraHeaders);
|
|
81
|
+
},
|
|
82
|
+
rawPut(path, body, extraHeaders = {}) {
|
|
83
|
+
return raw("PUT", path, body, extraHeaders);
|
|
84
|
+
},
|
|
85
|
+
rawPatch(path, body, extraHeaders = {}) {
|
|
86
|
+
return raw("PATCH", path, body, extraHeaders);
|
|
87
|
+
},
|
|
88
|
+
rawDelete(path, extraHeaders = {}) {
|
|
89
|
+
return raw("DELETE", path, null, extraHeaders);
|
|
90
|
+
},
|
|
91
|
+
getWithHeaders,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeReq(baseUrl, routeHeaders = {}, getHeaders = null) {
|
|
96
|
+
return createHttpClient({
|
|
97
|
+
baseUrl,
|
|
98
|
+
routeHeaders,
|
|
99
|
+
getHeaders,
|
|
100
|
+
}).request;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function makeRawReq(baseUrl, routeHeaders = {}, getRawHeaders = null) {
|
|
104
|
+
return createHttpClient({
|
|
105
|
+
baseUrl,
|
|
106
|
+
routeHeaders,
|
|
107
|
+
getRawHeaders,
|
|
108
|
+
}).raw;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function makeGetWithHeaders(baseUrl, routeHeaders = {}, getHeaders = null) {
|
|
112
|
+
return createHttpClient({
|
|
113
|
+
baseUrl,
|
|
114
|
+
routeHeaders,
|
|
115
|
+
getHeaders,
|
|
116
|
+
}).getWithHeaders;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runHttpRequest(method, url, body, headers) {
|
|
120
|
+
const options = { headers };
|
|
121
|
+
|
|
122
|
+
if (method === "GET") return http.get(url, options);
|
|
123
|
+
if (method === "PUT") return http.put(url, JSON.stringify(body), options);
|
|
124
|
+
if (method === "POST") return http.post(url, JSON.stringify(body), options);
|
|
125
|
+
if (method === "PATCH") return http.patch(url, JSON.stringify(body), options);
|
|
126
|
+
if (method === "DELETE") return http.del(url, null, options);
|
|
127
|
+
|
|
128
|
+
throw new Error(`unsupported method: ${method}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function safeHeaders(builder, setupData) {
|
|
132
|
+
if (typeof builder !== "function") return {};
|
|
133
|
+
return builder(setupData) || {};
|
|
134
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defaultOptions } from "./checks.js";
|
|
2
|
+
import { createHttpClient, getEnv } from "./http.js";
|
|
3
|
+
|
|
4
|
+
export function defineHttpSuite(configOrRun, maybeRun) {
|
|
5
|
+
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
6
|
+
const env = config.env || getEnv();
|
|
7
|
+
const auth = config.auth || null;
|
|
8
|
+
|
|
9
|
+
const client = createHttpClient({
|
|
10
|
+
baseUrl: env.BASE,
|
|
11
|
+
routeHeaders: env.routeParams,
|
|
12
|
+
getHeaders(setupData) {
|
|
13
|
+
return {
|
|
14
|
+
...callHeaders(auth?.headers, setupData, env),
|
|
15
|
+
...callHeaders(config.headers, setupData, env),
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
getRawHeaders(setupData) {
|
|
19
|
+
return callHeaders(config.rawHeaders, setupData, env);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
options: config.options || defaultOptions,
|
|
25
|
+
setup() {
|
|
26
|
+
if (typeof auth?.setup !== "function") return null;
|
|
27
|
+
return auth.setup({ env });
|
|
28
|
+
},
|
|
29
|
+
exec(setupData) {
|
|
30
|
+
return run({
|
|
31
|
+
env,
|
|
32
|
+
req: client.request,
|
|
33
|
+
rawReq: client.raw,
|
|
34
|
+
getWithHeaders: client.getWithHeaders,
|
|
35
|
+
setupData,
|
|
36
|
+
session: setupData,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
43
|
+
if (typeof configOrRun === "function") {
|
|
44
|
+
return { config: {}, run: configOrRun };
|
|
45
|
+
}
|
|
46
|
+
if (typeof maybeRun !== "function") {
|
|
47
|
+
throw new Error("suite factory requires a run callback");
|
|
48
|
+
}
|
|
49
|
+
return { config: configOrRun || {}, run: maybeRun };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function callHeaders(builder, setupData, env) {
|
|
53
|
+
if (typeof builder !== "function") return {};
|
|
54
|
+
return builder(setupData, { env }) || {};
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "CLI for running manifest-defined local test suites across k6 and Playwright",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./lib/index.mjs",
|
|
8
|
+
"./k6": "./lib/k6/index.mjs",
|
|
9
|
+
"./k6/*": "./lib/k6/*.mjs",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
6
12
|
"bin": {
|
|
7
13
|
"testkit": "bin/testkit.mjs"
|
|
8
14
|
},
|
|
@@ -23,6 +29,7 @@
|
|
|
23
29
|
},
|
|
24
30
|
"dependencies": {
|
|
25
31
|
"cac": "^6.7.14",
|
|
32
|
+
"esbuild": "^0.25.11",
|
|
26
33
|
"execa": "^9.5.0"
|
|
27
34
|
},
|
|
28
35
|
"engines": {
|