@elench/testkit 0.1.2 → 0.1.3

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/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
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
- }
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);
47
+ export function loadConfigs(opts = {}) {
48
+ const cwd = process.cwd();
49
+ const productDir = resolveProductDir(cwd, opts.dir);
54
50
  const manifestPath = path.join(productDir, "runner.manifest.json");
55
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
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
136
  if (fs.existsSync(path.join(p, "runner.manifest.json"))) return p;
112
137
  throw new Error(`No runner.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, "runner.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 runner.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, "runner.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
  */
@@ -111,7 +144,6 @@ export async function runTests(config, files) {
111
144
  await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
112
145
  } catch (err) {
113
146
  failed = true;
114
- // Continue running remaining files — report all failures, don't stop at first
115
147
  }
116
148
  }
117
149
  return { failed };
@@ -158,15 +190,18 @@ export function showStatus(config) {
158
190
  }
159
191
  for (const file of fs.readdirSync(stateDir)) {
160
192
  if (file === "fly-env.sh") continue;
161
- const val = fs.readFileSync(path.join(stateDir, file), "utf8").trim();
193
+ const filePath = path.join(stateDir, file);
194
+ if (fs.statSync(filePath).isDirectory()) continue;
195
+ const val = fs.readFileSync(filePath, "utf8").trim();
162
196
  console.log(` ${file}: ${val}`);
163
197
  }
164
198
  }
165
199
 
166
200
  /**
167
- * Main entry: orchestrate HTTP and/or DAL test flows.
201
+ * Run a single service: orchestrate HTTP and/or DAL test flows.
202
+ * Returns true if any tests failed.
168
203
  */
169
- export async function run(config, suiteType, suiteNames, opts) {
204
+ async function runService(config, suiteType, suiteNames, opts) {
170
205
  const { manifest, stateDir } = config;
171
206
 
172
207
  // Collect test files from manifest, partitioned by flow
@@ -189,7 +224,8 @@ export async function run(config, suiteType, suiteNames, opts) {
189
224
  }
190
225
 
191
226
  if (!httpFiles.length && !dalFiles.length) {
192
- throw new Error(`No test files found for type="${suiteType}" suites=${suiteNames.join(",") || "all"}`);
227
+ console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
228
+ return false;
193
229
  }
194
230
 
195
231
  fs.mkdirSync(stateDir, { recursive: true });
@@ -199,7 +235,7 @@ export async function run(config, suiteType, suiteNames, opts) {
199
235
 
200
236
  // HTTP flow: build → neon → fly → run → fly down
201
237
  if (httpFiles.length) {
202
- requireFlyToken(manifest.product);
238
+ requireFlyToken(config.name);
203
239
  if (opts.build) await build(config);
204
240
  await neonUp(config);
205
241
  neonReady = true;
@@ -219,5 +255,5 @@ export async function run(config, suiteType, suiteNames, opts) {
219
255
  if (result?.failed) failed = true;
220
256
  }
221
257
 
222
- if (failed) process.exit(1);
258
+ return failed;
223
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {