@elench/testkit 0.1.2 → 0.1.4

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,6 +1,6 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `runner.manifest.json` from a product repo, spins up an ephemeral environment (Neon DB branch + Fly machine), runs k6 tests, and tears down.
3
+ CLI that reads `testkit.manifest.json` from a product repo, spins up ephemeral infrastructure (Neon DB branch + Fly machine) per service, runs k6 tests, and tears down.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -13,41 +13,50 @@ fly auth login
13
13
 
14
14
  ## Setup
15
15
 
16
- Set platform secrets in your shell (e.g. `.envrc`):
16
+ Add platform secrets to each product's `.env`:
17
17
 
18
18
  ```bash
19
- export NEON_API_KEY='...'
20
- export FLY_API_TOKEN='...'
19
+ NEON_API_KEY='...'
20
+ FLY_API_TOKEN='...'
21
21
  ```
22
22
 
23
- Product secrets (`JWT_SECRET`, `ENCRYPTION_KEY`, etc.) are loaded from each product's `.env`.
23
+ Product secrets (`CLERK_SECRET_KEY`, etc.) are also loaded from the same `.env`.
24
24
 
25
25
  ## Usage
26
26
 
27
- From the `elench/` workspace root:
28
-
29
27
  ```bash
30
- # Full run — build, create env, test, stop machine
31
- node testkit/bin/testkit.mjs bourne --build
28
+ cd bourne
32
29
 
33
- # Fast re-run — reuse machine + branch, reset tables
34
- node testkit/bin/testkit.mjs bourne
30
+ # Run all suites
31
+ npx @elench/testkit
35
32
 
36
33
  # Specific type / suite
37
- node testkit/bin/testkit.mjs bourne int -s health
38
- node testkit/bin/testkit.mjs outreach e2e
34
+ npx @elench/testkit int -s health
35
+ npx @elench/testkit e2e
36
+
37
+ # Specific service (multi-service products)
38
+ npx @elench/testkit avocado_api int -s health
39
+
40
+ # Build from source first
41
+ npx @elench/testkit int --build
39
42
 
40
43
  # Lifecycle
41
- node testkit/bin/testkit.mjs bourne status
42
- node testkit/bin/testkit.mjs bourne destroy
44
+ npx @elench/testkit status
45
+ npx @elench/testkit destroy
43
46
  ```
44
47
 
45
48
  ## How it works
46
49
 
47
- 1. **Config** — reads `<product>/runner.manifest.json` for infra config + test suites
48
- 2. **Neon** — creates/reuses a persistent branch, truncates tables between runs
49
- 3. **Fly** — builds image (if `--build`), creates/reuses a machine with the right env vars
50
+ 1. **Config** — reads `testkit.manifest.json` for per-service infra config + test suites
51
+ 2. **Neon** — discovers or creates a `<service>-test` branch, truncates tables between runs
52
+ 3. **Fly** — discovers or creates a machine on the service's test app, updates env vars
50
53
  4. **k6** — runs matched test files with `BASE_URL` and `MACHINE_ID` injected
51
54
  5. **Cleanup** — stops the Fly machine (preserved for next run)
52
55
 
53
- State is persisted in `<product>/.testkit/` so subsequent runs skip setup.
56
+ Multi-service products run all services in parallel. Each service gets its own Neon branch and Fly machine.
57
+
58
+ State is persisted in `.testkit/` (or `.testkit/<service>/` for multi-service) so subsequent runs reuse existing infrastructure.
59
+
60
+ ## Manifest schema
61
+
62
+ See [testkit-manifest-schema.md](testkit-manifest-schema.md).
package/infra/neon-up.sh CHANGED
@@ -51,9 +51,17 @@ if [ -z "$BRANCH_ID" ]; then
51
51
 
52
52
  BRANCH_ID=$(echo "$RESPONSE" | jq -r '.branch.id')
53
53
  if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then
54
- echo "ERROR: Failed to create branch"
55
- echo "$RESPONSE" | jq .
56
- exit 1
54
+ # Create failed may be a 409 race (another parallel service created it).
55
+ # Re-discover by name before giving up.
56
+ BRANCH_ID=$(curl -sf "$NEON_API/branches" \
57
+ -H "Authorization: Bearer $NEON_API_KEY" \
58
+ | jq -r --arg name "$BRANCH_NAME" '.branches[] | select(.name == $name) | .id' | head -1)
59
+
60
+ if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then
61
+ echo "ERROR: Failed to create or discover branch '$BRANCH_NAME'"
62
+ exit 1
63
+ fi
64
+ echo "Branch created by another process — discovered: $BRANCH_ID"
57
65
  fi
58
66
  echo "$BRANCH_ID" > "$STATE_DIR/neon_branch_id"
59
67
 
package/lib/cli.mjs CHANGED
@@ -1,50 +1,77 @@
1
1
  import { cac } from "cac";
2
- import { loadConfig, inferProduct } from "./config.mjs";
2
+ import { loadConfigs, getServiceNames, isSiblingProduct } from "./config.mjs";
3
3
  import * as runner from "./runner.mjs";
4
4
 
5
5
  const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
6
6
  const LIFECYCLE = new Set(["status", "destroy"]);
7
+ const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
7
8
 
8
9
  export function run() {
9
10
  const cli = cac("testkit");
10
11
 
11
12
  cli
12
- .command("[first] [second]", "Run test suites (int, e2e, dal, all)")
13
+ .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
13
14
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
14
15
  .option("--build", "Build image from source first", { default: false })
15
16
  .option("--dir <path>", "Explicit product directory")
16
- .action(async (first, second, options) => {
17
- // Resolve product and type from positional args.
18
- // Supports: testkit outreach int -s health
19
- // testkit int -s health (infer product from cwd)
20
- // testkit outreach (type defaults to "all")
21
- // testkit (infer product, type "all")
22
- let product, type;
23
-
24
- if (first && !SUITE_TYPES.has(first) && !LIFECYCLE.has(first)) {
25
- // First arg is a product name
26
- product = first;
27
- type = second;
28
- } else {
29
- // First arg is a type/lifecycle or missing — infer product from cwd
30
- product = inferProduct();
31
- type = first; // second is ignored (no product was given)
17
+ .action(async (first, second, third, options) => {
18
+ // Resolve: service filter, suite type, and --dir.
19
+ //
20
+ // From product dir:
21
+ // testkit all services, all types
22
+ // testkit int -s health → all services, int, health
23
+ // testkit avocado_api int → one service, int
24
+ // testkit avocado_api → one service, all types
25
+ //
26
+ // From workspace root:
27
+ // testkit --dir outreach int → all services, int
28
+ // testkit --dir avocado avocado_api int → one service, int
29
+ //
30
+ // Legacy (sibling dir as first arg):
31
+ // testkit outreach int → --dir outreach, int
32
+
33
+ let service = null;
34
+ let type = null;
35
+
36
+ // If first arg is a sibling dir with a manifest, treat it as --dir
37
+ if (first && !RESERVED.has(first) && !options.dir && isSiblingProduct(first)) {
38
+ options.dir = first;
39
+ first = second;
40
+ second = third;
41
+ third = undefined;
32
42
  }
33
43
 
34
- // Handle lifecycle subcommands
35
- if (type === "status") {
36
- const config = loadConfig(product, options);
37
- return runner.showStatus(config);
44
+ // Now resolve service vs type from remaining args
45
+ const serviceNames = new Set(getServiceNames(options.dir));
46
+
47
+ if (first && serviceNames.has(first)) {
48
+ service = first;
49
+ type = second || null;
50
+ } else if (first && RESERVED.has(first)) {
51
+ type = first;
52
+ } else if (first) {
53
+ // Unknown arg — might be a service name that doesn't exist
54
+ throw new Error(
55
+ `Unknown argument "${first}". Expected a service name (${[...serviceNames].join(", ") || "none found"}) ` +
56
+ `or suite type (int, e2e, dal, all).`
57
+ );
38
58
  }
39
- if (type === "destroy") {
40
- const config = loadConfig(product, options);
41
- return runner.destroy(config);
59
+
60
+ const configs = loadConfigs({ dir: options.dir, service });
61
+
62
+ // Lifecycle commands
63
+ if (type === "status" || type === "destroy") {
64
+ for (const config of configs) {
65
+ if (configs.length > 1) console.log(`\n── ${config.name} ──`);
66
+ if (type === "status") runner.showStatus(config);
67
+ else await runner.destroy(config);
68
+ }
69
+ return;
42
70
  }
43
71
 
44
- const config = loadConfig(product, options);
45
72
  const suiteType = type || "all";
46
73
  const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
47
- await runner.run(config, suiteType, suiteNames, options);
74
+ await runner.runAll(configs, suiteType, suiteNames, options);
48
75
  });
49
76
 
50
77
  cli.help();
package/lib/config.mjs CHANGED
@@ -25,66 +25,89 @@ export function parseDotenv(filePath) {
25
25
  }
26
26
 
27
27
  /**
28
- * Infer the product name from ./runner.manifest.json in the cwd.
29
- * Used when the CLI is invoked without an explicit product argument.
28
+ * Read the manifest and return the service names.
29
+ * Used by the CLI to resolve ambiguous positional args.
30
30
  */
31
- export function inferProduct(cwd) {
31
+ export function getServiceNames(cwd) {
32
32
  const dir = cwd || process.cwd();
33
- const manifestPath = path.join(dir, "runner.manifest.json");
34
- if (!fs.existsSync(manifestPath)) {
35
- throw new Error(
36
- `No runner.manifest.json in current directory. ` +
37
- `Either cd into a product directory or pass the product name as the first argument.`
38
- );
39
- }
33
+ const manifestPath = path.join(dir, "testkit.manifest.json");
34
+ if (!fs.existsSync(manifestPath)) return [];
40
35
  const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
41
- if (typeof m.product !== "string" || !m.product) {
42
- throw new Error(`runner.manifest.json is missing a "product" field`);
43
- }
44
- return m.product;
36
+ if (!isObject(m.services)) return [];
37
+ return Object.keys(m.services);
45
38
  }
46
39
 
47
40
  /**
48
- * Find the product directory and load the manifest + env.
49
- * Returns { productDir, manifest, stateDir }.
41
+ * Load configs for all (or one) service from the manifest.
42
+ * Returns an array of { name, productDir, stateDir, manifest } objects.
43
+ *
44
+ * opts.service — filter to a single service name
45
+ * opts.dir — explicit product directory
50
46
  */
51
- export function loadConfig(product, opts = {}) {
52
- const cwd = opts.cwd || process.cwd();
53
- const productDir = resolveProductDir(product, cwd, opts.dir);
54
- const manifestPath = path.join(productDir, "runner.manifest.json");
55
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
47
+ export function loadConfigs(opts = {}) {
48
+ const cwd = process.cwd();
49
+ const productDir = resolveProductDir(cwd, opts.dir);
50
+ const manifestPath = path.join(productDir, "testkit.manifest.json");
51
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
56
52
 
57
- validateManifest(manifest, manifestPath);
53
+ if (!isObject(raw.services)) {
54
+ throw new Error(`Manifest must have a "services" object (${manifestPath})`);
55
+ }
58
56
 
59
57
  // Load product .env into process.env (product wins on conflict)
60
58
  const dotenv = parseDotenv(path.join(productDir, ".env"));
61
59
  Object.assign(process.env, dotenv);
62
60
 
63
- // Only require NEON_API_KEY unconditionally — Fly token is checked on demand
64
61
  if (!process.env.NEON_API_KEY) {
65
62
  throw new Error(
66
- `NEON_API_KEY not found. Set it in your shell environment, .envrc, or ${product}/.env`
63
+ `NEON_API_KEY not found. Set it in your shell environment, .envrc, or .env`
67
64
  );
68
65
  }
69
66
 
70
- // Validate manifest secrets are available
71
- const tk = manifest.testkit;
72
- for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || []), ...(tk.dal?.secrets || [])]) {
73
- if (!process.env[key]) {
74
- throw new Error(`Secret "${key}" (from manifest) not found in env or ${product}/.env`);
75
- }
67
+ const entries = Object.entries(raw.services);
68
+
69
+ // Filter by service name if requested
70
+ const filtered = opts.service
71
+ ? entries.filter(([name]) => name === opts.service)
72
+ : entries;
73
+
74
+ if (opts.service && filtered.length === 0) {
75
+ const available = entries.map(([n]) => n).join(", ");
76
+ throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
76
77
  }
77
78
 
78
- return { productDir, manifest, stateDir: path.join(productDir, ".testkit") };
79
+ return filtered.map(([name, svc]) => {
80
+ validateService(name, svc, manifestPath);
81
+
82
+ // Check service secrets are available
83
+ const tk = svc.testkit;
84
+ for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || []), ...(tk.dal?.secrets || [])]) {
85
+ if (!process.env[key]) {
86
+ throw new Error(`Secret "${key}" (service "${name}") not found in env or .env`);
87
+ }
88
+ }
89
+
90
+ // Each service gets its own state dir: .testkit/<service>/
91
+ const stateDir = entries.length === 1
92
+ ? path.join(productDir, ".testkit")
93
+ : path.join(productDir, ".testkit", name);
94
+
95
+ return {
96
+ name,
97
+ productDir,
98
+ stateDir,
99
+ manifest: { testkit: tk, suites: svc.suites },
100
+ };
101
+ });
79
102
  }
80
103
 
81
104
  /**
82
105
  * Require FLY_API_TOKEN. Call this only when HTTP tests need Fly infrastructure.
83
106
  */
84
- export function requireFlyToken(product) {
107
+ export function requireFlyToken(serviceName) {
85
108
  if (!process.env.FLY_API_TOKEN) {
86
109
  throw new Error(
87
- `FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or ${product}/.env`
110
+ `FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or .env`
88
111
  );
89
112
  }
90
113
  }
@@ -105,157 +128,120 @@ export function resolveDalBinary(config) {
105
128
  return abs;
106
129
  }
107
130
 
108
- function resolveProductDir(product, cwd, explicitDir) {
131
+ // ── Directory resolution ────────────────────────────────────────────────
132
+
133
+ function resolveProductDir(cwd, explicitDir) {
109
134
  if (explicitDir) {
110
135
  const p = path.resolve(cwd, explicitDir);
111
- if (fs.existsSync(path.join(p, "runner.manifest.json"))) return p;
112
- throw new Error(`No runner.manifest.json in ${p}`);
136
+ if (fs.existsSync(path.join(p, "testkit.manifest.json"))) return p;
137
+ throw new Error(`No testkit.manifest.json in ${p}`);
113
138
  }
114
139
 
115
- // Try ./bourne/runner.manifest.json (workspace root)
116
- const sibling = path.join(cwd, product);
117
- if (fs.existsSync(path.join(sibling, "runner.manifest.json"))) return sibling;
118
-
119
- // Try ./runner.manifest.json (already in product dir)
120
- const here = path.join(cwd, "runner.manifest.json");
121
- if (fs.existsSync(here)) {
122
- const m = JSON.parse(fs.readFileSync(here, "utf8"));
123
- if (m.product === product) return cwd;
124
- }
140
+ // Check cwd
141
+ if (fs.existsSync(path.join(cwd, "testkit.manifest.json"))) return cwd;
125
142
 
126
143
  throw new Error(
127
- `Could not find runner.manifest.json for "${product}". ` +
128
- `Run from the workspace root or use --dir.`
144
+ `No testkit.manifest.json in current directory. ` +
145
+ `Either cd into a product directory or use --dir.`
129
146
  );
130
147
  }
131
148
 
132
- // --- Schema validation ---
149
+ /**
150
+ * Check if the given name (relative to cwd) is a sibling directory with a manifest.
151
+ * Used by the CLI to distinguish dir args from service/type args.
152
+ */
153
+ export function isSiblingProduct(name) {
154
+ const candidate = path.join(process.cwd(), name);
155
+ return fs.existsSync(path.join(candidate, "testkit.manifest.json"));
156
+ }
133
157
 
134
- function validateManifest(manifest, manifestPath) {
135
- const errors = [];
136
- const ctx = `in ${manifestPath}`;
158
+ // ── Per-service validation ──────────────────────────────────────────────
137
159
 
138
- requireString(errors, manifest, "product", ctx);
160
+ function validateService(name, svc, manifestPath) {
161
+ const errors = [];
162
+ const ctx = `service "${name}" in ${manifestPath}`;
139
163
 
140
- if (!isObject(manifest.testkit)) {
141
- errors.push(`Manifest error: testkit is required and must be an object (${ctx})`);
164
+ if (!isObject(svc.testkit)) {
165
+ errors.push(`${ctx}: testkit is required and must be an object`);
142
166
  } else {
143
- const tk = manifest.testkit;
167
+ const tk = svc.testkit;
144
168
 
145
- // testkit.neon
146
169
  if (!isObject(tk.neon)) {
147
- errors.push(`Manifest error: testkit.neon is required and must be an object (${ctx})`);
170
+ errors.push(`${ctx}: testkit.neon is required`);
148
171
  } else {
149
- requireString(errors, tk.neon, "testkit.neon.projectId", ctx, "projectId");
150
- requireString(errors, tk.neon, "testkit.neon.dbName", ctx, "dbName");
172
+ requireString(errors, tk.neon, `${ctx}: testkit.neon.projectId`, "projectId");
173
+ requireString(errors, tk.neon, `${ctx}: testkit.neon.dbName`, "dbName");
151
174
  }
152
175
 
153
- // testkit.fly
154
176
  if (!isObject(tk.fly)) {
155
- errors.push(`Manifest error: testkit.fly is required and must be an object (${ctx})`);
177
+ errors.push(`${ctx}: testkit.fly is required`);
156
178
  } else {
157
- requireString(errors, tk.fly, "testkit.fly.app", ctx, "app");
158
- requireString(errors, tk.fly, "testkit.fly.org", ctx, "org");
159
- optionalString(errors, tk.fly, "testkit.fly.region", ctx, "region");
160
- optionalString(errors, tk.fly, "testkit.fly.port", ctx, "port");
161
- optionalStringRecord(errors, tk.fly, "testkit.fly.env", ctx, "env");
162
- optionalStringArray(errors, tk.fly, "testkit.fly.secrets", ctx, "secrets");
179
+ requireString(errors, tk.fly, `${ctx}: testkit.fly.app`, "app");
180
+ requireString(errors, tk.fly, `${ctx}: testkit.fly.org`, "org");
181
+ optionalStringArray(errors, tk.fly, `${ctx}: testkit.fly.secrets`, "secrets");
163
182
  }
164
183
 
165
- // testkit.k6
166
- if (tk.k6 !== undefined) {
167
- if (!isObject(tk.k6)) {
168
- errors.push(`Manifest error: testkit.k6 must be an object (${ctx})`);
169
- } else {
170
- optionalStringArray(errors, tk.k6, "testkit.k6.secrets", ctx, "secrets");
171
- }
184
+ if (tk.k6 !== undefined && !isObject(tk.k6)) {
185
+ errors.push(`${ctx}: testkit.k6 must be an object`);
172
186
  }
173
187
 
174
- // testkit.dal
175
188
  if (tk.dal !== undefined) {
176
189
  if (!isObject(tk.dal)) {
177
- errors.push(`Manifest error: testkit.dal must be an object (${ctx})`);
178
- } else {
179
- // k6Binary is required if suites.dal exists
180
- if (manifest.suites?.dal) {
181
- requireString(errors, tk.dal, "testkit.dal.k6Binary", ctx, "k6Binary");
182
- }
183
- optionalStringArray(errors, tk.dal, "testkit.dal.secrets", ctx, "secrets");
190
+ errors.push(`${ctx}: testkit.dal must be an object`);
191
+ } else if (svc.suites?.dal) {
192
+ requireString(errors, tk.dal, `${ctx}: testkit.dal.k6Binary`, "k6Binary");
184
193
  }
185
194
  }
195
+
196
+ if (svc.suites?.dal && !tk.dal) {
197
+ errors.push(`${ctx}: testkit.dal is required when suites.dal exists`);
198
+ }
186
199
  }
187
200
 
188
- // suites
189
- if (!isObject(manifest.suites)) {
190
- errors.push(`Manifest error: suites is required and must be an object (${ctx})`);
201
+ if (!isObject(svc.suites)) {
202
+ errors.push(`${ctx}: suites is required and must be an object`);
191
203
  } else {
192
- const keys = Object.keys(manifest.suites);
193
- if (keys.length === 0) {
194
- errors.push(`Manifest error: suites must have at least one key (${ctx})`);
195
- }
196
- for (const type of keys) {
197
- const arr = manifest.suites[type];
204
+ for (const type of Object.keys(svc.suites)) {
205
+ const arr = svc.suites[type];
198
206
  if (!Array.isArray(arr)) {
199
- errors.push(`Manifest error: suites.${type} must be an array (${ctx})`);
207
+ errors.push(`${ctx}: suites.${type} must be an array`);
200
208
  continue;
201
209
  }
202
210
  for (let i = 0; i < arr.length; i++) {
203
211
  const suite = arr[i];
204
- const prefix = `suites.${type}[${i}]`;
205
- requireString(errors, suite, `${prefix}.name`, ctx, "name");
206
- requireNonEmptyStringArray(errors, suite, `${prefix}.files`, ctx, "files");
212
+ const prefix = `${ctx}: suites.${type}[${i}]`;
213
+ requireString(errors, suite, `${prefix}.name`, "name");
214
+ requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
207
215
  }
208
216
  }
209
217
  }
210
218
 
211
- // dal.k6Binary required if suites.dal exists but testkit.dal is missing
212
- if (manifest.suites?.dal && !manifest.testkit?.dal) {
213
- errors.push(`Manifest error: testkit.dal is required when suites.dal exists (${ctx})`);
214
- }
215
-
216
- if (errors.length) {
217
- throw new Error(errors.join("\n"));
218
- }
219
+ if (errors.length) throw new Error(errors.join("\n"));
219
220
  }
220
221
 
222
+ // ── Helpers ─────────────────────────────────────────────────────────────
223
+
221
224
  function isObject(v) {
222
225
  return v !== null && typeof v === "object" && !Array.isArray(v);
223
226
  }
224
227
 
225
- function requireString(errors, obj, path, ctx, key) {
226
- const k = key || path;
227
- const val = obj[k];
228
- if (typeof val !== "string" || val.length === 0) {
229
- errors.push(`Manifest error: ${path} is required (${ctx})`);
228
+ function requireString(errors, obj, msg, key) {
229
+ if (typeof obj[key] !== "string" || obj[key].length === 0) {
230
+ errors.push(`${msg} is required`);
230
231
  }
231
232
  }
232
233
 
233
- function optionalString(errors, obj, path, ctx, key) {
234
- const val = obj[key];
235
- if (val !== undefined && typeof val !== "string") {
236
- errors.push(`Manifest error: ${path} must be a string (${ctx})`);
237
- }
238
- }
239
-
240
- function optionalStringArray(errors, obj, path, ctx, key) {
234
+ function optionalStringArray(errors, obj, msg, key) {
241
235
  const val = obj[key];
242
236
  if (val === undefined) return;
243
237
  if (!Array.isArray(val) || !val.every(v => typeof v === "string")) {
244
- errors.push(`Manifest error: ${path} must be a string[] (${ctx})`);
245
- }
246
- }
247
-
248
- function optionalStringRecord(errors, obj, path, ctx, key) {
249
- const val = obj[key];
250
- if (val === undefined) return;
251
- if (!isObject(val) || !Object.values(val).every(v => typeof v === "string")) {
252
- errors.push(`Manifest error: ${path} must be a Record<string, string> (${ctx})`);
238
+ errors.push(`${msg} must be a string[]`);
253
239
  }
254
240
  }
255
241
 
256
- function requireNonEmptyStringArray(errors, obj, path, ctx, key) {
242
+ function requireNonEmptyStringArray(errors, obj, msg, key) {
257
243
  const val = obj[key];
258
244
  if (!Array.isArray(val) || val.length === 0 || !val.every(v => typeof v === "string")) {
259
- errors.push(`Manifest error: ${path} is required and must be a non-empty string[] (${ctx})`);
245
+ errors.push(`${msg} is required and must be a non-empty string[]`);
260
246
  }
261
247
  }
package/lib/runner.mjs CHANGED
@@ -6,6 +6,39 @@ import { requireFlyToken, resolveDalBinary } from "./config.mjs";
6
6
 
7
7
  const HTTP_SUITE_TYPES = new Set(["integration", "e2e", "load"]);
8
8
 
9
+ /**
10
+ * Run multiple service configs in parallel.
11
+ * Single service: runs directly. Multiple: Promise.allSettled.
12
+ */
13
+ export async function runAll(configs, suiteType, suiteNames, opts) {
14
+ if (configs.length === 1) {
15
+ const failed = await runService(configs[0], suiteType, suiteNames, opts);
16
+ if (failed) process.exit(1);
17
+ return;
18
+ }
19
+
20
+ // Multiple services — run in parallel
21
+ const results = await Promise.allSettled(
22
+ configs.map(async (config) => {
23
+ console.log(`\n══ ${config.name} ══`);
24
+ return runService(config, suiteType, suiteNames, opts);
25
+ })
26
+ );
27
+
28
+ // Summarize
29
+ const summary = configs.map((c, i) => {
30
+ const r = results[i];
31
+ const ok = r.status === "fulfilled" && !r.value;
32
+ return ` ${ok ? "✓" : "✗"} ${c.name}`;
33
+ });
34
+ console.log(`\n── Summary ──\n${summary.join("\n")}`);
35
+
36
+ const anyFailed = results.some(
37
+ (r) => r.status === "rejected" || r.value === true
38
+ );
39
+ if (anyFailed) process.exit(1);
40
+ }
41
+
9
42
  /**
10
43
  * Build the Docker image and push to Fly registry.
11
44
  */
@@ -29,6 +62,7 @@ export async function neonUp(config) {
29
62
  await runScript("neon-up.sh", {
30
63
  NEON_PROJECT_ID: tk.neon.projectId,
31
64
  NEON_DB_NAME: tk.neon.dbName,
65
+ NEON_BRANCH_NAME: tk.neon.branchName || `${config.name}-test`,
32
66
  STATE_DIR: stateDir,
33
67
  });
34
68
  }
@@ -111,7 +145,6 @@ export async function runTests(config, files) {
111
145
  await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
112
146
  } catch (err) {
113
147
  failed = true;
114
- // Continue running remaining files — report all failures, don't stop at first
115
148
  }
116
149
  }
117
150
  return { failed };
@@ -158,15 +191,18 @@ export function showStatus(config) {
158
191
  }
159
192
  for (const file of fs.readdirSync(stateDir)) {
160
193
  if (file === "fly-env.sh") continue;
161
- const val = fs.readFileSync(path.join(stateDir, file), "utf8").trim();
194
+ const filePath = path.join(stateDir, file);
195
+ if (fs.statSync(filePath).isDirectory()) continue;
196
+ const val = fs.readFileSync(filePath, "utf8").trim();
162
197
  console.log(` ${file}: ${val}`);
163
198
  }
164
199
  }
165
200
 
166
201
  /**
167
- * Main entry: orchestrate HTTP and/or DAL test flows.
202
+ * Run a single service: orchestrate HTTP and/or DAL test flows.
203
+ * Returns true if any tests failed.
168
204
  */
169
- export async function run(config, suiteType, suiteNames, opts) {
205
+ async function runService(config, suiteType, suiteNames, opts) {
170
206
  const { manifest, stateDir } = config;
171
207
 
172
208
  // Collect test files from manifest, partitioned by flow
@@ -189,7 +225,8 @@ export async function run(config, suiteType, suiteNames, opts) {
189
225
  }
190
226
 
191
227
  if (!httpFiles.length && !dalFiles.length) {
192
- throw new Error(`No test files found for type="${suiteType}" suites=${suiteNames.join(",") || "all"}`);
228
+ console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
229
+ return false;
193
230
  }
194
231
 
195
232
  fs.mkdirSync(stateDir, { recursive: true });
@@ -199,7 +236,7 @@ export async function run(config, suiteType, suiteNames, opts) {
199
236
 
200
237
  // HTTP flow: build → neon → fly → run → fly down
201
238
  if (httpFiles.length) {
202
- requireFlyToken(manifest.product);
239
+ requireFlyToken(config.name);
203
240
  if (opts.build) await build(config);
204
241
  await neonUp(config);
205
242
  neonReady = true;
@@ -219,5 +256,5 @@ export async function run(config, suiteType, suiteNames, opts) {
219
256
  if (result?.failed) failed = true;
220
257
  }
221
258
 
222
- if (failed) process.exit(1);
259
+ return failed;
223
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {