@elench/testkit 0.1.1 → 0.1.2

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.
@@ -29,6 +29,16 @@ if [ -f "$STATE_DIR/fly_image" ]; then
29
29
  fi
30
30
  fi
31
31
 
32
+ # Skip if image already exists in registry (e.g. built by a previous run without state)
33
+ if git -C "$API_DIR" diff --quiet 2>/dev/null; then
34
+ fly auth docker 2>/dev/null
35
+ if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
36
+ echo "Image $IMAGE already in registry — skipping build"
37
+ echo "$IMAGE" > "$STATE_DIR/fly_image"
38
+ exit 0
39
+ fi
40
+ fi
41
+
32
42
  # Ensure the Fly app exists (registry requires it)
33
43
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
44
  bash "$SCRIPT_DIR/fly-app-ensure.sh"
package/infra/fly-up.sh CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Ensures a Fly machine is running with the correct env vars and image.
3
3
  # Reuses an existing stopped machine if available; creates a new one if not.
4
- # Requires: FLY_APP, FLY_REGION, FLY_IMAGE
4
+ # Requires: FLY_APP, FLY_REGION
5
+ # Image required only for --build or new machines (FLY_IMAGE / FLY_IMAGE_DEFAULT / .state/fly_image)
5
6
  # Optional: DB_URL_ENV_NAME (default: DATABASE_URL), FLY_PORT, FLY_ENV_FILE
6
7
  set -eo pipefail
7
8
 
@@ -15,12 +16,11 @@ FLY_REGION="${FLY_REGION:-lhr}"
15
16
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
17
  bash "$SCRIPT_DIR/fly-app-ensure.sh"
17
18
 
18
- # Image resolution: explicit $FLY_IMAGE .state/fly_image $FLY_IMAGE_DEFAULT error
19
+ # Resolve image if available (not required yet may not be needed for reuse)
19
20
  if [ -z "$FLY_IMAGE" ] && [ -f "$STATE_DIR/fly_image" ]; then
20
21
  FLY_IMAGE="$(cat "$STATE_DIR/fly_image")"
21
22
  fi
22
23
  FLY_IMAGE="${FLY_IMAGE:-$FLY_IMAGE_DEFAULT}"
23
- FLY_IMAGE="${FLY_IMAGE:?FLY_IMAGE required — run fly-build.sh first or set FLY_IMAGE}"
24
24
 
25
25
  if [ ! -f "$STATE_DIR/database_url" ]; then
26
26
  echo "ERROR: No database_url — run neon-up.sh first"
@@ -40,33 +40,56 @@ fi
40
40
 
41
41
  MACHINE_ID=""
42
42
 
43
- # ── Try to reuse existing machine ────────────────────────────────────────
43
+ # ── 1. Try state file ────────────────────────────────────────────────────
44
44
  if [ -f "$STATE_DIR/fly_machine_id" ]; then
45
45
  EXISTING_ID=$(cat "$STATE_DIR/fly_machine_id")
46
46
  if fly machines status "$EXISTING_ID" --app "$FLY_APP" >/dev/null 2>&1; then
47
- echo "Reusing machine $EXISTING_ID — updating config"
48
-
49
- # Update env vars + image (handles DATABASE_URL change and code changes)
50
- # shellcheck disable=SC2086
51
- fly machines update "$EXISTING_ID" --app "$FLY_APP" \
52
- --env "$DB_URL_ENV_NAME=$DATABASE_URL" \
53
- $FLY_ENV_FLAGS \
54
- --image "$FLY_IMAGE" \
55
- --yes 2>&1
56
-
57
- # Start if not already running (update may auto-restart)
58
- fly machines start "$EXISTING_ID" --app "$FLY_APP" 2>/dev/null || true
59
- fly machines wait "$EXISTING_ID" --app "$FLY_APP" --state started --wait-timeout 60s
60
-
61
47
  MACHINE_ID="$EXISTING_ID"
62
48
  else
63
- echo "Stored machine $EXISTING_ID gone — creating new"
49
+ echo "Stored machine $EXISTING_ID gone — will discover or create"
64
50
  rm -f "$STATE_DIR/fly_machine_id"
65
51
  fi
66
52
  fi
67
53
 
68
- # ── Create new machine if needed ─────────────────────────────────────────
54
+ # ── 2. Discover existing machine on the app ──────────────────────────────
55
+ if [ -z "$MACHINE_ID" ]; then
56
+ DISCOVERED_ID=$(fly machines list --app "$FLY_APP" --json 2>/dev/null \
57
+ | jq -r '.[0].id // empty')
58
+ if [ -n "$DISCOVERED_ID" ]; then
59
+ MACHINE_ID="$DISCOVERED_ID"
60
+ echo "Discovered existing machine: $MACHINE_ID"
61
+ fi
62
+ fi
63
+
64
+ # ── 3. Update and start existing machine ─────────────────────────────────
65
+ if [ -n "$MACHINE_ID" ]; then
66
+ echo "Reusing machine $MACHINE_ID — updating config"
67
+
68
+ # Build update flags: always update DATABASE_URL + target env
69
+ UPDATE_FLAGS="--env $DB_URL_ENV_NAME=$DATABASE_URL $FLY_ENV_FLAGS"
70
+
71
+ # Only update image if one was provided (--build was used)
72
+ if [ -n "$FLY_IMAGE" ]; then
73
+ UPDATE_FLAGS="$UPDATE_FLAGS --image $FLY_IMAGE"
74
+ fi
75
+
76
+ # shellcheck disable=SC2086
77
+ fly machines update "$MACHINE_ID" --app "$FLY_APP" \
78
+ $UPDATE_FLAGS \
79
+ --yes 2>&1
80
+
81
+ # Start if not already running (update may auto-restart)
82
+ fly machines start "$MACHINE_ID" --app "$FLY_APP" 2>/dev/null || true
83
+ fly machines wait "$MACHINE_ID" --app "$FLY_APP" --state started --wait-timeout 60s
84
+
85
+ echo "$MACHINE_ID" > "$STATE_DIR/fly_machine_id"
86
+ fi
87
+
88
+ # ── 4. Create new machine if needed ──────────────────────────────────────
69
89
  if [ -z "$MACHINE_ID" ]; then
90
+ # Image is required for new machines
91
+ FLY_IMAGE="${FLY_IMAGE:?FLY_IMAGE required — run with --build or set FLY_IMAGE}"
92
+
70
93
  echo "Creating Fly machine (app: $FLY_APP, region: $FLY_REGION)"
71
94
 
72
95
  # shellcheck disable=SC2086
package/infra/neon-up.sh CHANGED
@@ -11,7 +11,7 @@ NEON_API="https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID"
11
11
 
12
12
  mkdir -p "$STATE_DIR"
13
13
 
14
- # ── Check for existing branch ────────────────────────────────────────────
14
+ # ── 1. Check state file ──────────────────────────────────────────────────
15
15
  BRANCH_ID=""
16
16
  if [ -f "$STATE_DIR/neon_branch_id" ]; then
17
17
  STORED_ID=$(cat "$STATE_DIR/neon_branch_id")
@@ -20,12 +20,25 @@ if [ -f "$STATE_DIR/neon_branch_id" ]; then
20
20
  BRANCH_ID="$STORED_ID"
21
21
  echo "Neon branch exists: $BRANCH_ID"
22
22
  else
23
- echo "Stored branch $STORED_ID gone — creating new"
23
+ echo "Stored branch $STORED_ID gone — will discover or create"
24
24
  rm -f "$STATE_DIR/neon_branch_id"
25
25
  fi
26
26
  fi
27
27
 
28
- # ── Create branch if needed ──────────────────────────────────────────────
28
+ # ── 2. Discover existing branch by name ──────────────────────────────────
29
+ if [ -z "$BRANCH_ID" ]; then
30
+ EXISTING_ID=$(curl -sf "$NEON_API/branches" \
31
+ -H "Authorization: Bearer $NEON_API_KEY" \
32
+ | jq -r --arg name "$BRANCH_NAME" '.branches[] | select(.name == $name) | .id' | head -1)
33
+
34
+ if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
35
+ BRANCH_ID="$EXISTING_ID"
36
+ echo "$BRANCH_ID" > "$STATE_DIR/neon_branch_id"
37
+ echo "Discovered existing Neon branch '$BRANCH_NAME': $BRANCH_ID"
38
+ fi
39
+ fi
40
+
41
+ # ── 3. Create branch if needed ───────────────────────────────────────────
29
42
  if [ -z "$BRANCH_ID" ]; then
30
43
  echo "Creating Neon branch: $BRANCH_NAME"
31
44
  RESPONSE=$(curl -sf -X POST "$NEON_API/branches" \
package/lib/cli.mjs CHANGED
@@ -1,16 +1,36 @@
1
1
  import { cac } from "cac";
2
- import { loadConfig } from "./config.mjs";
2
+ import { loadConfig, inferProduct } from "./config.mjs";
3
3
  import * as runner from "./runner.mjs";
4
4
 
5
+ const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
6
+ const LIFECYCLE = new Set(["status", "destroy"]);
7
+
5
8
  export function run() {
6
9
  const cli = cac("testkit");
7
10
 
8
11
  cli
9
- .command("<product> [type]", "Run test suites")
12
+ .command("[first] [second]", "Run test suites (int, e2e, dal, all)")
10
13
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
11
14
  .option("--build", "Build image from source first", { default: false })
12
15
  .option("--dir <path>", "Explicit product directory")
13
- .action(async (product, type, options) => {
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)
32
+ }
33
+
14
34
  // Handle lifecycle subcommands
15
35
  if (type === "status") {
16
36
  const config = loadConfig(product, options);
package/lib/config.mjs CHANGED
@@ -24,6 +24,26 @@ export function parseDotenv(filePath) {
24
24
  return env;
25
25
  }
26
26
 
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.
30
+ */
31
+ export function inferProduct(cwd) {
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
+ }
40
+ 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;
45
+ }
46
+
27
47
  /**
28
48
  * Find the product directory and load the manifest + env.
29
49
  * Returns { productDir, manifest, stateDir }.
@@ -34,26 +54,22 @@ export function loadConfig(product, opts = {}) {
34
54
  const manifestPath = path.join(productDir, "runner.manifest.json");
35
55
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
36
56
 
37
- if (!manifest.testkit) {
38
- throw new Error(`No "testkit" section in ${manifestPath}`);
39
- }
57
+ validateManifest(manifest, manifestPath);
40
58
 
41
59
  // Load product .env into process.env (product wins on conflict)
42
60
  const dotenv = parseDotenv(path.join(productDir, ".env"));
43
61
  Object.assign(process.env, dotenv);
44
62
 
45
- // Validate required infra keys
46
- for (const key of ["NEON_API_KEY", "FLY_API_TOKEN"]) {
47
- if (!process.env[key]) {
48
- throw new Error(
49
- `${key} not found. Set it in your shell environment, .envrc, or ${product}/.env`
50
- );
51
- }
63
+ // Only require NEON_API_KEY unconditionally — Fly token is checked on demand
64
+ if (!process.env.NEON_API_KEY) {
65
+ throw new Error(
66
+ `NEON_API_KEY not found. Set it in your shell environment, .envrc, or ${product}/.env`
67
+ );
52
68
  }
53
69
 
54
70
  // Validate manifest secrets are available
55
71
  const tk = manifest.testkit;
56
- for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || [])]) {
72
+ for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || []), ...(tk.dal?.secrets || [])]) {
57
73
  if (!process.env[key]) {
58
74
  throw new Error(`Secret "${key}" (from manifest) not found in env or ${product}/.env`);
59
75
  }
@@ -62,6 +78,33 @@ export function loadConfig(product, opts = {}) {
62
78
  return { productDir, manifest, stateDir: path.join(productDir, ".testkit") };
63
79
  }
64
80
 
81
+ /**
82
+ * Require FLY_API_TOKEN. Call this only when HTTP tests need Fly infrastructure.
83
+ */
84
+ export function requireFlyToken(product) {
85
+ if (!process.env.FLY_API_TOKEN) {
86
+ throw new Error(
87
+ `FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or ${product}/.env`
88
+ );
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Resolve the custom k6 binary for DAL tests. Returns the absolute path.
94
+ */
95
+ export function resolveDalBinary(config) {
96
+ const { productDir, manifest } = config;
97
+ const rel = manifest.testkit.dal?.k6Binary;
98
+ if (!rel) {
99
+ throw new Error("testkit.dal.k6Binary is required for DAL tests");
100
+ }
101
+ const abs = path.resolve(productDir, rel);
102
+ if (!fs.existsSync(abs)) {
103
+ throw new Error(`DAL k6 binary not found: ${abs}`);
104
+ }
105
+ return abs;
106
+ }
107
+
65
108
  function resolveProductDir(product, cwd, explicitDir) {
66
109
  if (explicitDir) {
67
110
  const p = path.resolve(cwd, explicitDir);
@@ -85,3 +128,134 @@ function resolveProductDir(product, cwd, explicitDir) {
85
128
  `Run from the workspace root or use --dir.`
86
129
  );
87
130
  }
131
+
132
+ // --- Schema validation ---
133
+
134
+ function validateManifest(manifest, manifestPath) {
135
+ const errors = [];
136
+ const ctx = `in ${manifestPath}`;
137
+
138
+ requireString(errors, manifest, "product", ctx);
139
+
140
+ if (!isObject(manifest.testkit)) {
141
+ errors.push(`Manifest error: testkit is required and must be an object (${ctx})`);
142
+ } else {
143
+ const tk = manifest.testkit;
144
+
145
+ // testkit.neon
146
+ if (!isObject(tk.neon)) {
147
+ errors.push(`Manifest error: testkit.neon is required and must be an object (${ctx})`);
148
+ } else {
149
+ requireString(errors, tk.neon, "testkit.neon.projectId", ctx, "projectId");
150
+ requireString(errors, tk.neon, "testkit.neon.dbName", ctx, "dbName");
151
+ }
152
+
153
+ // testkit.fly
154
+ if (!isObject(tk.fly)) {
155
+ errors.push(`Manifest error: testkit.fly is required and must be an object (${ctx})`);
156
+ } 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");
163
+ }
164
+
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
+ }
172
+ }
173
+
174
+ // testkit.dal
175
+ if (tk.dal !== undefined) {
176
+ 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");
184
+ }
185
+ }
186
+ }
187
+
188
+ // suites
189
+ if (!isObject(manifest.suites)) {
190
+ errors.push(`Manifest error: suites is required and must be an object (${ctx})`);
191
+ } 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];
198
+ if (!Array.isArray(arr)) {
199
+ errors.push(`Manifest error: suites.${type} must be an array (${ctx})`);
200
+ continue;
201
+ }
202
+ for (let i = 0; i < arr.length; i++) {
203
+ 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");
207
+ }
208
+ }
209
+ }
210
+
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
+ }
220
+
221
+ function isObject(v) {
222
+ return v !== null && typeof v === "object" && !Array.isArray(v);
223
+ }
224
+
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})`);
230
+ }
231
+ }
232
+
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) {
241
+ const val = obj[key];
242
+ if (val === undefined) return;
243
+ 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})`);
253
+ }
254
+ }
255
+
256
+ function requireNonEmptyStringArray(errors, obj, path, ctx, key) {
257
+ const val = obj[key];
258
+ 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})`);
260
+ }
261
+ }
package/lib/runner.mjs CHANGED
@@ -2,6 +2,9 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { runScript } from "./exec.mjs";
4
4
  import { execaCommand } from "execa";
5
+ import { requireFlyToken, resolveDalBinary } from "./config.mjs";
6
+
7
+ const HTTP_SUITE_TYPES = new Set(["integration", "e2e", "load"]);
5
8
 
6
9
  /**
7
10
  * Build the Docker image and push to Fly registry.
@@ -84,7 +87,7 @@ export async function destroy(config) {
84
87
  }
85
88
 
86
89
  /**
87
- * Run k6 test files with the correct env flags.
90
+ * Run k6 test files against the Fly machine (HTTP tests).
88
91
  */
89
92
  export async function runTests(config, files) {
90
93
  const { productDir, stateDir, manifest } = config;
@@ -114,6 +117,36 @@ export async function runTests(config, files) {
114
117
  return { failed };
115
118
  }
116
119
 
120
+ /**
121
+ * Run k6 DAL test files directly against Neon (no Fly machine).
122
+ */
123
+ export async function runDalTests(config, files) {
124
+ const { productDir, stateDir, manifest } = config;
125
+ const tk = manifest.testkit;
126
+ const k6Binary = resolveDalBinary(config);
127
+
128
+ // Read DATABASE_URL from neon state
129
+ const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
130
+
131
+ // Build -e flags
132
+ const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
133
+ for (const key of tk.dal?.secrets || []) {
134
+ envFlags.push(`-e ${key}=${process.env[key]}`);
135
+ }
136
+ const envStr = envFlags.join(" ");
137
+
138
+ let failed = false;
139
+ for (const file of files) {
140
+ const absFile = path.join(productDir, file);
141
+ try {
142
+ await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
143
+ } catch (err) {
144
+ failed = true;
145
+ }
146
+ }
147
+ return { failed };
148
+ }
149
+
117
150
  /**
118
151
  * Show state directory contents.
119
152
  */
@@ -131,41 +164,60 @@ export function showStatus(config) {
131
164
  }
132
165
 
133
166
  /**
134
- * Main entry: build neon fly → k6 → stop.
167
+ * Main entry: orchestrate HTTP and/or DAL test flows.
135
168
  */
136
169
  export async function run(config, suiteType, suiteNames, opts) {
137
170
  const { manifest, stateDir } = config;
138
171
 
139
- // Collect test files from manifest
172
+ // Collect test files from manifest, partitioned by flow
140
173
  const types = suiteType === "all"
141
174
  ? Object.keys(manifest.suites)
142
175
  : [suiteType === "int" ? "integration" : suiteType];
143
176
 
144
- let files = [];
177
+ let httpFiles = [];
178
+ let dalFiles = [];
145
179
  for (const type of types) {
146
180
  const suites = manifest.suites[type] || [];
147
181
  for (const suite of suites) {
148
182
  if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
149
- files.push(...suite.files);
183
+ if (type === "dal") {
184
+ dalFiles.push(...suite.files);
185
+ } else {
186
+ httpFiles.push(...suite.files);
187
+ }
150
188
  }
151
189
  }
152
190
 
153
- if (!files.length) {
191
+ if (!httpFiles.length && !dalFiles.length) {
154
192
  throw new Error(`No test files found for type="${suiteType}" suites=${suiteNames.join(",") || "all"}`);
155
193
  }
156
194
 
157
195
  fs.mkdirSync(stateDir, { recursive: true });
158
196
 
159
- if (opts.build) await build(config);
160
- await neonUp(config);
161
- await flyUp(config);
197
+ let failed = false;
198
+ let neonReady = false;
199
+
200
+ // HTTP flow: build → neon → fly → run → fly down
201
+ if (httpFiles.length) {
202
+ requireFlyToken(manifest.product);
203
+ if (opts.build) await build(config);
204
+ await neonUp(config);
205
+ neonReady = true;
206
+ await flyUp(config);
207
+ try {
208
+ const result = await runTests(config, httpFiles);
209
+ if (result?.failed) failed = true;
210
+ } finally {
211
+ await flyDown(config);
212
+ }
213
+ }
162
214
 
163
- let result;
164
- try {
165
- result = await runTests(config, files);
166
- } finally {
167
- await flyDown(config);
215
+ // DAL flow: neon (idempotent) → run DAL tests
216
+ if (dalFiles.length) {
217
+ if (!neonReady) await neonUp(config);
218
+ const result = await runDalTests(config, dalFiles);
219
+ if (result?.failed) failed = true;
168
220
  }
169
221
 
170
- if (result?.failed) process.exit(1);
222
+ if (failed) process.exit(1);
171
223
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
+ "description": "CLI for running k6 tests against real, ephemeral infrastructure",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "testkit": "bin/testkit.mjs"