@elench/testkit 0.1.26 → 0.1.28

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
@@ -1,12 +1,10 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `testkit.config.json` from a product repo, discovers `*.testkit.ts` files by convention, starts the required local services, provisions local Postgres isolation, and runs HTTP, DAL, and Playwright suites.
3
+ `@elench/testkit` discovers `*.testkit.ts` files, infers suite ownership from the
4
+ filesystem, starts local services, provisions isolated local Postgres databases,
5
+ and runs HTTP, DAL, and Playwright suites.
4
6
 
5
- ## Prerequisites
6
-
7
- `@elench/testkit` ships its own execution engine for HTTP and DAL suites. Consumers do not need to install or invoke any separate load-testing binary.
8
-
9
- 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`.
7
+ The package is now driven by `testkit.setup.ts`, not `testkit.config.json`.
10
8
 
11
9
  ## Usage
12
10
 
@@ -30,14 +28,13 @@ npx @elench/testkit --jobs 3
30
28
 
31
29
  # Run a deterministic shard
32
30
  npx @elench/testkit --shard 1/3
33
- npx @elench/testkit --jobs 2 --shard 2/3
34
31
 
35
32
  # Specific service / suite
36
33
  npx @elench/testkit frontend e2e -s auth
37
34
  npx @elench/testkit api int -s health
38
35
 
39
36
  # Exact file
40
- npx @elench/testkit int --file tests/api/integration/health.int.testkit.ts
37
+ npx @elench/testkit int --file __testkit__/health/health.int.testkit.ts
41
38
 
42
39
  # Deterministic git-trackable status snapshot
43
40
  npx @elench/testkit int --write-status
@@ -47,113 +44,145 @@ npx @elench/testkit status
47
44
  npx @elench/testkit destroy
48
45
  ```
49
46
 
47
+ ## Setup
48
+
49
+ Create `testkit.setup.ts` at repo root:
50
+
51
+ ```ts
52
+ import {
53
+ defineTestkitSetup,
54
+ lifecycle,
55
+ localDatabase,
56
+ nextService,
57
+ service,
58
+ tsxService,
59
+ } from "@elench/testkit/setup";
60
+
61
+ export default defineTestkitSetup({
62
+ services: {
63
+ api: service({
64
+ ...tsxService({
65
+ cwd: ".",
66
+ entry: "src/index.ts",
67
+ port: 3004,
68
+ readyPath: "/health",
69
+ }),
70
+ envFiles: [".env.testkit"],
71
+ database: localDatabase(),
72
+ migrate: lifecycle("npm run db:migrate", {
73
+ testkitCmd: "npm run db:migrate:testkit",
74
+ }),
75
+ }),
76
+ frontend: service({
77
+ ...nextService({
78
+ cwd: "frontend",
79
+ port: 3000,
80
+ env: {
81
+ NEXT_PUBLIC_API_URL: "{baseUrl:api}",
82
+ },
83
+ }),
84
+ dependsOn: ["api"],
85
+ envFiles: ["frontend/.env.testkit"],
86
+ }),
87
+ },
88
+ });
89
+ ```
90
+
91
+ `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
92
+ for:
93
+
94
+ - multi-service graphs
95
+ - local DB configuration
96
+ - migrate / seed commands
97
+ - test-local migrate / seed overrides
98
+ - named HTTP suite profiles
99
+ - telemetry upload configuration
100
+
50
101
  ## Authoring
51
102
 
52
- Consumer tests can import the shared authoring API directly from the package:
103
+ HTTP suites:
53
104
 
54
- ```js
105
+ ```ts
55
106
  import { defineHttpSuite } from "@elench/testkit";
56
107
 
57
108
  const suite = defineHttpSuite(({ rawReq }) => {
58
- const res = rawReq("GET", "/health");
109
+ rawReq("GET", "/health");
59
110
  });
60
111
 
61
- export const options = suite.options;
62
- export const setup = suite.setup;
63
- export default suite.exec;
112
+ export default suite;
64
113
  ```
65
114
 
66
- For auth or schema-specific behavior, keep a small consumer-owned adapter next
67
- to the tests and pass it into the generic suite factory:
115
+ `testkit` suite files should default-export the suite object returned by
116
+ `defineHttpSuite(...)` or `defineDalSuite(...)`.
117
+
118
+ Named HTTP profiles live in `testkit.setup.ts` and can be referenced by name:
68
119
 
69
- ```js
120
+ ```ts
70
121
  import { defineHttpSuite } from "@elench/testkit";
71
- import { clerkSessionAuth } from "../helpers/testkit-auth.js";
72
122
 
73
- const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) => {
74
- req("GET", "/api/auth/me", setupData);
123
+ const suite = defineHttpSuite({ profile: "default-auth" }, ({ req, setupData }) => {
124
+ req("GET", "/api/auth/session", setupData);
75
125
  });
76
126
  ```
77
127
 
78
- Low-level runtime primitives are also available without exposing the underlying engine:
79
-
80
- ```js
81
- import { check, group, http } from "@elench/testkit/runtime";
82
- ```
83
-
84
- `testkit` bundles these imports before execution, so tests do not need
85
- generated `_testkit` files, direct package-manager path imports, or any separate engine installation.
86
- The published package also ships first-party TypeScript declarations for both
87
- `@elench/testkit` and `@elench/testkit/runtime`, so consumer repos do not need
88
- local ambient module shims for the supported authoring surface.
89
-
90
- Legacy compatibility:
91
-
92
- - `testkit runtime install`
93
- - `testkit runtime status`
94
- - `testkit runtime update`
128
+ DAL suites:
95
129
 
96
- still exist, but direct package imports are now the preferred model.
130
+ ```ts
131
+ import { defineDalSuite } from "@elench/testkit";
97
132
 
98
- From outside the product repo, use `--dir` explicitly:
133
+ const suite = defineDalSuite(({ db }) => {
134
+ db.query("select 1");
135
+ });
99
136
 
100
- ```bash
101
- npx @elench/testkit --dir my-product int
102
- npx @elench/testkit --dir my-product api int -s health
137
+ export default suite;
103
138
  ```
104
139
 
105
- ## How it works
106
-
107
- 1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
108
- `*.int.testkit.ts`, `*.e2e.testkit.ts`, `*.dal.testkit.ts`, `*.load.testkit.ts`, `*.pw.testkit.ts`
109
- 2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
110
- Per-service `.env` files declared in config are loaded when present.
111
- 3. **Database** — provisions Docker-managed local Postgres when a service declares one
112
- 4. **Seed** — runs optional product seed commands against the provisioned database
113
- 5. **Runtime** — starts required local services, waits for readiness, and injects test env
114
- 6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles test files before execution so package imports resolve cleanly, and batches Playwright files per worker
115
- 7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
140
+ Low-level runtime primitives remain available:
116
141
 
117
- Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
118
-
119
- `testkit` also writes `.testkit/results/latest.json` for every run. With `--write-status`, it writes a deterministic `testkit.status.json` snapshot that is suitable for git tracking. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload the richer run artifact to a configured HTTP endpoint with bearer auth.
142
+ ```ts
143
+ import { check, group, http } from "@elench/testkit/runtime";
144
+ ```
120
145
 
121
- ## File roles
146
+ ## Discovery
122
147
 
123
- - `testkit.config.json`: local execution and provisioning config
124
- - `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
148
+ `testkit` discovers suites from `__testkit__/` directories.
125
149
 
126
- `testkit.config.json` can also declare:
150
+ Example layouts:
127
151
 
128
- - `telemetry` for optional generic HTTP result upload
129
- - no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
130
- - `envFile` / `envFiles` for service-specific environment loading
131
- - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
132
- - `database.provider` for local Postgres settings
133
- - `database.template.inputs` to define the local template cache invalidation inputs
134
- - `migrate.backends` / `seed.backends` for optional local-only command overrides
152
+ - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
153
+ - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
154
+ - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
155
+ - `avocado_api/internal/handler/__testkit__/repos/crud.int.testkit.ts`
135
156
 
136
- ## Parallel execution
157
+ `testkit` uses these suffixes automatically:
137
158
 
138
- `@elench/testkit` can run test work in parallel with `--jobs <n>`.
159
+ - `*.int.testkit.ts`
160
+ - `*.e2e.testkit.ts`
161
+ - `*.dal.testkit.ts`
162
+ - `*.load.testkit.ts`
163
+ - `*.pw.testkit.ts`
139
164
 
140
- `--jobs` is global for the whole run, not per service.
165
+ Ownership is inferred from:
141
166
 
142
- Each worker gets its own:
167
+ - the deepest matching service root from `services.<name>.local.cwd`
168
+ - optional `services.<name>.discovery.roots` overrides for shared-root edge cases
143
169
 
144
- - cloned local Postgres database
145
- - `.testkit` state subtree
146
- - local service ports
170
+ Suite names are inferred from the colocated path:
147
171
 
148
- 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`.
172
+ - `auth/__testkit__/*.int.testkit.ts` => `auth`
173
+ - `routes/__testkit__/auth/*.int.testkit.ts` => `auth`
149
174
 
150
- Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
175
+ ## Local Databases
151
176
 
152
- `testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
177
+ `@elench/testkit` provisions Docker-managed local Postgres automatically for
178
+ services that define `database: localDatabase(...)`.
153
179
 
154
- ## Schema
180
+ - template databases are cached
181
+ - worker databases are cloned from templates
182
+ - template fingerprints are derived automatically from env files, migrate/seed
183
+ config, and repo contents
155
184
 
156
- See [testkit-config-schema.md](testkit-config-schema.md).
185
+ Manual `template.inputs` overrides are still available for edge cases.
157
186
 
158
187
  ## Development Tests
159
188
 
@@ -163,8 +192,3 @@ npm run test:unit
163
192
  npm run test:integration
164
193
  npm run test:system
165
194
  ```
166
-
167
- `test:system` runs real end-to-end fixtures from `test/fixtures/system/`. It requires:
168
-
169
- - Docker with a running daemon
170
- - Playwright Chromium browser assets available to `@playwright/test`
@@ -4,9 +4,11 @@ import path from "path";
4
4
  import crypto from "crypto";
5
5
  import { build } from "esbuild";
6
6
  import { fileURLToPath } from "url";
7
+ import { findSetupFile } from "../config/setup-loader.mjs";
7
8
 
8
9
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
10
  const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
11
+ const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
10
12
  const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
11
13
  const bundleCache = new Map();
12
14
 
@@ -19,7 +21,8 @@ export async function bundleK6File({
19
21
  const bundleDir = path.join(productDir, ".testkit", "_bundles", serviceName || "shared");
20
22
  fs.mkdirSync(bundleDir, { recursive: true });
21
23
 
22
- const cacheKey = await buildCacheKey(absoluteSource);
24
+ const setupFile = findSetupFile(productDir);
25
+ const cacheKey = await buildCacheKey(absoluteSource, setupFile);
23
26
  const cached = bundleCache.get(cacheKey);
24
27
  if (cached && fs.existsSync(cached)) {
25
28
  return cached;
@@ -29,11 +32,19 @@ export async function bundleK6File({
29
32
  bundleDir,
30
33
  `${path.basename(sourceFile, path.extname(sourceFile))}-${cacheKey.slice(0, 12)}.js`
31
34
  );
35
+ const entryFile = path.join(
36
+ bundleDir,
37
+ `${path.basename(sourceFile, path.extname(sourceFile))}-${cacheKey.slice(0, 12)}.entry.mjs`
38
+ );
39
+ fs.writeFileSync(entryFile, buildBundleEntryModule({
40
+ sourceFile: absoluteSource,
41
+ setupFile,
42
+ }));
32
43
 
33
44
  await build({
34
- absWorkingDir: path.dirname(absoluteSource),
45
+ absWorkingDir: bundleDir,
35
46
  bundle: true,
36
- entryPoints: [absoluteSource],
47
+ entryPoints: [entryFile],
37
48
  format: "esm",
38
49
  legalComments: "none",
39
50
  outfile: outputFile,
@@ -51,17 +62,23 @@ export async function bundleK6File({
51
62
  return outputFile;
52
63
  }
53
64
 
54
- async function buildCacheKey(sourceFile) {
65
+ async function buildCacheKey(sourceFile, setupFile = null) {
55
66
  const source = await fs.promises.readFile(sourceFile, "utf8");
56
67
  const packageJson = await fs.promises.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8");
57
- return crypto
68
+ const hash = crypto
58
69
  .createHash("sha256")
59
70
  .update(sourceFile)
60
71
  .update("\0")
61
72
  .update(source)
62
73
  .update("\0")
63
- .update(packageJson)
64
- .digest("hex");
74
+ .update(packageJson);
75
+
76
+ if (setupFile && fs.existsSync(setupFile)) {
77
+ hash.update("\0").update(setupFile).update("\0");
78
+ hash.update(await fs.promises.readFile(setupFile, "utf8"));
79
+ }
80
+
81
+ return hash.digest("hex");
65
82
  }
66
83
 
67
84
  function testkitPackageAliasPlugin() {
@@ -81,7 +98,58 @@ function testkitPackageAliasPlugin() {
81
98
  function resolvePackageSubpath(specifier) {
82
99
  const subpath = specifier.slice("@elench/testkit".length);
83
100
  if (!subpath) return ROOT_ENTRY;
101
+ if (subpath === "/setup") return SETUP_ENTRY;
84
102
  if (subpath === "/runtime") return RUNTIME_ENTRY;
85
103
 
86
104
  throw new Error(`Unsupported @elench/testkit import "${specifier}" in ${os.platform()}`);
87
105
  }
106
+
107
+ function buildBundleEntryModule({ sourceFile, setupFile }) {
108
+ const sourceImport = JSON.stringify(sourceFile);
109
+ const setupRegistration = setupFile
110
+ ? `
111
+ import * as repoSetupModule from ${JSON.stringify(setupFile)};
112
+ registerRepoSetup(repoSetupModule.default || repoSetupModule || null);
113
+ `
114
+ : `
115
+ registerRepoSetup(null);
116
+ `;
117
+
118
+ return `
119
+ import { registerRepoSetup } from "@elench/testkit/setup";
120
+ import * as suiteModule from ${sourceImport};
121
+ ${setupRegistration}
122
+ const suite = normalizeTestkitSuite(suiteModule);
123
+ export const options = suite.options;
124
+ export function setup(...args) {
125
+ return suite.setup(...args);
126
+ }
127
+ export default function exec(...args) {
128
+ return suite.exec(...args);
129
+ }
130
+
131
+ function normalizeTestkitSuite(module) {
132
+ const candidate = module?.default;
133
+ if (!candidate || typeof candidate !== "object") {
134
+ throw new Error(
135
+ "testkit suite files must default-export the suite object returned by defineHttpSuite(...) or defineDalSuite(...). Example: export default defineHttpSuite(...) or const suite = defineHttpSuite(...); export default suite;"
136
+ );
137
+ }
138
+ if (typeof candidate.exec !== "function") {
139
+ throw new Error(
140
+ "testkit suite default export must expose an exec(setupData) function"
141
+ );
142
+ }
143
+
144
+ const setupFn = typeof candidate["setup"] === "function"
145
+ ? (...args) => candidate.setup(...args)
146
+ : () => null;
147
+
148
+ return {
149
+ options: candidate["options"],
150
+ setup: setupFn,
151
+ exec: (...args) => candidate.exec(...args),
152
+ };
153
+ }
154
+ `;
155
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
+ import { pathToFileURL } from "url";
4
5
  import { afterEach, describe, expect, it } from "vitest";
5
6
  import { bundleK6File } from "./index.mjs";
6
7
 
@@ -29,9 +30,7 @@ describe("runtime bundler", () => {
29
30
  ' "has status": (body) => typeof body.status === "string",',
30
31
  " });",
31
32
  "});",
32
- "export const options = suite.options;",
33
- "export const setup = suite.setup;",
34
- "export default suite.exec;",
33
+ "export default suite;",
35
34
  "",
36
35
  ].join("\n")
37
36
  );
@@ -60,9 +59,7 @@ describe("runtime bundler", () => {
60
59
  "const suite = defineDalSuite(({ db }) => {",
61
60
  ' db.query("SELECT 1");',
62
61
  "});",
63
- "export const options = suite.options;",
64
- "export const setup = suite.setup;",
65
- "export default suite.exec;",
62
+ "export default suite;",
66
63
  "",
67
64
  ].join("\n")
68
65
  );
@@ -77,4 +74,62 @@ describe("runtime bundler", () => {
77
74
  expect(bundled).toContain("defineDalSuite");
78
75
  expect(bundled).toContain('import sql from "k6/x/sql"');
79
76
  });
77
+
78
+ it("normalizes a default-exported suite object with no setup override", async () => {
79
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
80
+ cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
81
+
82
+ const sourceFile = path.join(tmpDir, "no-setup.js");
83
+ fs.writeFileSync(
84
+ sourceFile,
85
+ [
86
+ "const suite = {",
87
+ " options: { vus: 1, iterations: 1 },",
88
+ " exec() {",
89
+ " return 'ok';",
90
+ " },",
91
+ "};",
92
+ "export default suite;",
93
+ "",
94
+ ].join("\n")
95
+ );
96
+
97
+ const bundledFile = await bundleK6File({
98
+ productDir: tmpDir,
99
+ serviceName: "api",
100
+ sourceFile,
101
+ });
102
+
103
+ const bundled = await import(`${pathToFileURL(bundledFile).href}?v=${Date.now()}`);
104
+ expect(typeof bundled.setup).toBe("function");
105
+ expect(bundled.setup()).toBeNull();
106
+ expect(typeof bundled.default).toBe("function");
107
+ expect(bundled.default()).toBe("ok");
108
+ });
109
+
110
+ it("throws a clear error when the default export is not a suite object", async () => {
111
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
112
+ cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
113
+
114
+ const sourceFile = path.join(tmpDir, "legacy-shape.js");
115
+ fs.writeFileSync(
116
+ sourceFile,
117
+ [
118
+ "export default function exec() {",
119
+ " return 'legacy';",
120
+ "}",
121
+ "",
122
+ ].join("\n")
123
+ );
124
+
125
+ const bundledFile = await bundleK6File({
126
+ productDir: tmpDir,
127
+ serviceName: "api",
128
+ sourceFile,
129
+ });
130
+
131
+ await expect(
132
+ import(`${pathToFileURL(bundledFile).href}?v=${Date.now()}`)
133
+ ).rejects.toThrow(/default-export the suite object/);
134
+ });
80
135
  });
package/lib/cli/index.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { cac } from "cac";
2
- import { loadConfigs, getServiceNames } from "../config/index.mjs";
2
+ import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
4
  parseJobsOption,
5
5
  parseShardOption,
6
- RESERVED,
7
6
  resolveRequestedFiles,
8
7
  resolveCliSelection,
9
8
  validateFrameworkOption,
@@ -13,33 +12,6 @@ import * as runner from "../runner/index.mjs";
13
12
  export function run() {
14
13
  const cli = cac("testkit");
15
14
 
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(async (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
- const runtime = await import("../runtime-manager/index.mjs");
27
-
28
- if (action === "status") {
29
- const status = runtime.getRuntimeStatus(options);
30
- console.log(runtime.formatRuntimeStatus(status));
31
- if (options.strict && status.status !== "installed") {
32
- process.exitCode = 1;
33
- }
34
- return;
35
- }
36
-
37
- const result = runtime.installRuntime(options);
38
- console.log(
39
- `Installed testkit runtime to ${result.relativeRuntimeDir} (${result.files.length} file${result.files.length === 1 ? "" : "s"})`
40
- );
41
- });
42
-
43
15
  cli
44
16
  .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
45
17
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
@@ -68,14 +40,13 @@ export function run() {
68
40
  // testkit --dir my-product api int → one service, int
69
41
 
70
42
  // Now resolve service vs type from remaining args
71
- const serviceNames = new Set(getServiceNames(options.dir));
43
+ const allConfigs = await loadConfigs({ dir: options.dir });
44
+ const serviceNames = new Set(allConfigs.map((config) => config.name));
72
45
  const { service, type } = resolveCliSelection({
73
46
  first,
74
47
  second,
75
48
  serviceNames,
76
49
  });
77
-
78
- const allConfigs = loadConfigs({ dir: options.dir });
79
50
  const configs = service
80
51
  ? allConfigs.filter((config) => config.name === service)
81
52
  : allConfigs;