@elench/testkit 0.1.26 → 0.1.27

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,15 +44,69 @@ 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
112
  export const options = suite.options;
@@ -63,97 +114,72 @@ export const setup = suite.setup;
63
114
  export default suite.exec;
64
115
  ```
65
116
 
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:
117
+ Named HTTP profiles live in `testkit.setup.ts` and can be referenced by name:
68
118
 
69
- ```js
119
+ ```ts
70
120
  import { defineHttpSuite } from "@elench/testkit";
71
- import { clerkSessionAuth } from "../helpers/testkit-auth.js";
72
121
 
73
- const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) => {
74
- req("GET", "/api/auth/me", setupData);
122
+ const suite = defineHttpSuite({ profile: "default-auth" }, ({ req, setupData }) => {
123
+ req("GET", "/api/auth/session", setupData);
75
124
  });
76
125
  ```
77
126
 
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.
127
+ DAL suites:
89
128
 
90
- Legacy compatibility:
129
+ ```ts
130
+ import { defineDalSuite } from "@elench/testkit";
91
131
 
92
- - `testkit runtime install`
93
- - `testkit runtime status`
94
- - `testkit runtime update`
95
-
96
- still exist, but direct package imports are now the preferred model.
97
-
98
- From outside the product repo, use `--dir` explicitly:
99
-
100
- ```bash
101
- npx @elench/testkit --dir my-product int
102
- npx @elench/testkit --dir my-product api int -s health
132
+ const suite = defineDalSuite(({ db }) => {
133
+ db.query("select 1");
134
+ });
103
135
  ```
104
136
 
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
137
+ Low-level runtime primitives remain available:
116
138
 
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.
139
+ ```ts
140
+ import { check, group, http } from "@elench/testkit/runtime";
141
+ ```
120
142
 
121
- ## File roles
143
+ ## Discovery
122
144
 
123
- - `testkit.config.json`: local execution and provisioning config
124
- - `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
145
+ `testkit` discovers suites from `__testkit__/` directories.
125
146
 
126
- `testkit.config.json` can also declare:
147
+ Example layouts:
127
148
 
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
149
+ - `src/api/routes/__testkit__/auth/me.int.testkit.ts`
150
+ - `src/db/__testkit__/sessions/count-type.dal.testkit.ts`
151
+ - `frontend/__testkit__/navigation/navigation.pw.testkit.ts`
152
+ - `avocado_api/internal/handler/__testkit__/repos/crud.int.testkit.ts`
135
153
 
136
- ## Parallel execution
154
+ `testkit` uses these suffixes automatically:
137
155
 
138
- `@elench/testkit` can run test work in parallel with `--jobs <n>`.
156
+ - `*.int.testkit.ts`
157
+ - `*.e2e.testkit.ts`
158
+ - `*.dal.testkit.ts`
159
+ - `*.load.testkit.ts`
160
+ - `*.pw.testkit.ts`
139
161
 
140
- `--jobs` is global for the whole run, not per service.
162
+ Ownership is inferred from:
141
163
 
142
- Each worker gets its own:
164
+ - the deepest matching service root from `services.<name>.local.cwd`
165
+ - optional `services.<name>.discovery.roots` overrides for shared-root edge cases
143
166
 
144
- - cloned local Postgres database
145
- - `.testkit` state subtree
146
- - local service ports
167
+ Suite names are inferred from the colocated path:
147
168
 
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`.
169
+ - `auth/__testkit__/*.int.testkit.ts` => `auth`
170
+ - `routes/__testkit__/auth/*.int.testkit.ts` => `auth`
149
171
 
150
- Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
172
+ ## Local Databases
151
173
 
152
- `testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
174
+ `@elench/testkit` provisions Docker-managed local Postgres automatically for
175
+ services that define `database: localDatabase(...)`.
153
176
 
154
- ## Schema
177
+ - template databases are cached
178
+ - worker databases are cloned from templates
179
+ - template fingerprints are derived automatically from env files, migrate/seed
180
+ config, and repo contents
155
181
 
156
- See [testkit-config-schema.md](testkit-config-schema.md).
182
+ Manual `template.inputs` overrides are still available for edge cases.
157
183
 
158
184
  ## Development Tests
159
185
 
@@ -163,8 +189,3 @@ npm run test:unit
163
189
  npm run test:integration
164
190
  npm run test:system
165
191
  ```
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,29 @@ 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
+ export const options = suiteModule.options;
123
+ export const setup = suiteModule.setup;
124
+ export default suiteModule.default;
125
+ `;
126
+ }
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;