@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 +103 -82
- package/lib/bundler/index.mjs +46 -7
- package/lib/cli/index.mjs +3 -32
- package/lib/config/discovery.mjs +209 -54
- package/lib/config/discovery.test.mjs +57 -28
- package/lib/config/index.mjs +297 -154
- package/lib/config/setup-loader.mjs +98 -0
- package/lib/index.d.ts +1 -0
- package/lib/package.test.mjs +5 -0
- package/lib/runner/template.mjs +1 -1
- package/lib/runtime-src/k6/http.js +1 -0
- package/lib/runtime-src/k6/suite.js +66 -23
- package/lib/setup/index.d.ts +104 -0
- package/lib/setup/index.mjs +292 -0
- package/lib/setup/runtime.mjs +79 -0
- package/package.json +5 -1
- package/lib/config/model.mjs +0 -320
- package/lib/config/model.test.mjs +0 -163
- package/lib/runtime-manager/index.mjs +0 -190
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# @elench/testkit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
103
|
+
HTTP suites:
|
|
53
104
|
|
|
54
|
-
```
|
|
105
|
+
```ts
|
|
55
106
|
import { defineHttpSuite } from "@elench/testkit";
|
|
56
107
|
|
|
57
108
|
const suite = defineHttpSuite(({ rawReq }) => {
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
119
|
+
```ts
|
|
70
120
|
import { defineHttpSuite } from "@elench/testkit";
|
|
71
|
-
import { clerkSessionAuth } from "../helpers/testkit-auth.js";
|
|
72
121
|
|
|
73
|
-
const suite = defineHttpSuite({
|
|
74
|
-
req("GET", "/api/auth/
|
|
122
|
+
const suite = defineHttpSuite({ profile: "default-auth" }, ({ req, setupData }) => {
|
|
123
|
+
req("GET", "/api/auth/session", setupData);
|
|
75
124
|
});
|
|
76
125
|
```
|
|
77
126
|
|
|
78
|
-
|
|
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
|
-
|
|
129
|
+
```ts
|
|
130
|
+
import { defineDalSuite } from "@elench/testkit";
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
```ts
|
|
140
|
+
import { check, group, http } from "@elench/testkit/runtime";
|
|
141
|
+
```
|
|
120
142
|
|
|
121
|
-
##
|
|
143
|
+
## Discovery
|
|
122
144
|
|
|
123
|
-
|
|
124
|
-
- `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
|
|
145
|
+
`testkit` discovers suites from `__testkit__/` directories.
|
|
125
146
|
|
|
126
|
-
|
|
147
|
+
Example layouts:
|
|
127
148
|
|
|
128
|
-
- `
|
|
129
|
-
-
|
|
130
|
-
- `
|
|
131
|
-
- `
|
|
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
|
-
|
|
154
|
+
`testkit` uses these suffixes automatically:
|
|
137
155
|
|
|
138
|
-
|
|
156
|
+
- `*.int.testkit.ts`
|
|
157
|
+
- `*.e2e.testkit.ts`
|
|
158
|
+
- `*.dal.testkit.ts`
|
|
159
|
+
- `*.load.testkit.ts`
|
|
160
|
+
- `*.pw.testkit.ts`
|
|
139
161
|
|
|
140
|
-
|
|
162
|
+
Ownership is inferred from:
|
|
141
163
|
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
- `.testkit` state subtree
|
|
146
|
-
- local service ports
|
|
167
|
+
Suite names are inferred from the colocated path:
|
|
147
168
|
|
|
148
|
-
|
|
169
|
+
- `auth/__testkit__/*.int.testkit.ts` => `auth`
|
|
170
|
+
- `routes/__testkit__/auth/*.int.testkit.ts` => `auth`
|
|
149
171
|
|
|
150
|
-
|
|
172
|
+
## Local Databases
|
|
151
173
|
|
|
152
|
-
|
|
174
|
+
`@elench/testkit` provisions Docker-managed local Postgres automatically for
|
|
175
|
+
services that define `database: localDatabase(...)`.
|
|
153
176
|
|
|
154
|
-
|
|
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
|
-
|
|
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`
|
package/lib/bundler/index.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
45
|
+
absWorkingDir: bundleDir,
|
|
35
46
|
bundle: true,
|
|
36
|
-
entryPoints: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|