@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 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, 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
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";
@@ -0,0 +1 @@
1
+ export * from "../runtime-src/k6/http.js";
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export * from "../runtime-src/k6/suite.js";
@@ -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}`, absFile], {
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}`, absFile],
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.18",
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": {