@elench/testkit 0.1.1 → 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.
@@ -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" \
@@ -38,9 +51,17 @@ if [ -z "$BRANCH_ID" ]; then
38
51
 
39
52
  BRANCH_ID=$(echo "$RESPONSE" | jq -r '.branch.id')
40
53
  if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then
41
- echo "ERROR: Failed to create branch"
42
- echo "$RESPONSE" | jq .
43
- 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"
44
65
  fi
45
66
  echo "$BRANCH_ID" > "$STATE_DIR/neon_branch_id"
46
67
 
package/lib/cli.mjs CHANGED
@@ -1,30 +1,77 @@
1
1
  import { cac } from "cac";
2
- import { loadConfig } from "./config.mjs";
2
+ import { loadConfigs, getServiceNames, isSiblingProduct } 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
+ const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
8
+
5
9
  export function run() {
6
10
  const cli = cac("testkit");
7
11
 
8
12
  cli
9
- .command("<product> [type]", "Run test suites")
13
+ .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
10
14
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
11
15
  .option("--build", "Build image from source first", { default: false })
12
16
  .option("--dir <path>", "Explicit product directory")
13
- .action(async (product, type, options) => {
14
- // Handle lifecycle subcommands
15
- if (type === "status") {
16
- const config = loadConfig(product, options);
17
- return runner.showStatus(config);
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;
42
+ }
43
+
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
+ );
18
58
  }
19
- if (type === "destroy") {
20
- const config = loadConfig(product, options);
21
- 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;
22
70
  }
23
71
 
24
- const config = loadConfig(product, options);
25
72
  const suiteType = type || "all";
26
73
  const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
27
- await runner.run(config, suiteType, suiteNames, options);
74
+ await runner.runAll(configs, suiteType, suiteNames, options);
28
75
  });
29
76
 
30
77
  cli.help();
package/lib/config.mjs CHANGED
@@ -25,63 +25,223 @@ export function parseDotenv(filePath) {
25
25
  }
26
26
 
27
27
  /**
28
- * Find the product directory and load the manifest + env.
29
- * Returns { productDir, manifest, stateDir }.
28
+ * Read the manifest and return the service names.
29
+ * Used by the CLI to resolve ambiguous positional args.
30
30
  */
31
- export function loadConfig(product, opts = {}) {
32
- const cwd = opts.cwd || process.cwd();
33
- const productDir = resolveProductDir(product, cwd, opts.dir);
31
+ export function getServiceNames(cwd) {
32
+ const dir = cwd || process.cwd();
33
+ const manifestPath = path.join(dir, "runner.manifest.json");
34
+ if (!fs.existsSync(manifestPath)) return [];
35
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
36
+ if (!isObject(m.services)) return [];
37
+ return Object.keys(m.services);
38
+ }
39
+
40
+ /**
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
46
+ */
47
+ export function loadConfigs(opts = {}) {
48
+ const cwd = process.cwd();
49
+ const productDir = resolveProductDir(cwd, opts.dir);
34
50
  const manifestPath = path.join(productDir, "runner.manifest.json");
35
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
51
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
36
52
 
37
- if (!manifest.testkit) {
38
- throw new Error(`No "testkit" section in ${manifestPath}`);
53
+ if (!isObject(raw.services)) {
54
+ throw new Error(`Manifest must have a "services" object (${manifestPath})`);
39
55
  }
40
56
 
41
57
  // Load product .env into process.env (product wins on conflict)
42
58
  const dotenv = parseDotenv(path.join(productDir, ".env"));
43
59
  Object.assign(process.env, dotenv);
44
60
 
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
- }
61
+ if (!process.env.NEON_API_KEY) {
62
+ throw new Error(
63
+ `NEON_API_KEY not found. Set it in your shell environment, .envrc, or .env`
64
+ );
52
65
  }
53
66
 
54
- // Validate manifest secrets are available
55
- const tk = manifest.testkit;
56
- for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || [])]) {
57
- if (!process.env[key]) {
58
- throw new Error(`Secret "${key}" (from manifest) not found in env or ${product}/.env`);
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}`);
77
+ }
78
+
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
+ }
59
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
+ });
102
+ }
103
+
104
+ /**
105
+ * Require FLY_API_TOKEN. Call this only when HTTP tests need Fly infrastructure.
106
+ */
107
+ export function requireFlyToken(serviceName) {
108
+ if (!process.env.FLY_API_TOKEN) {
109
+ throw new Error(
110
+ `FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or .env`
111
+ );
60
112
  }
113
+ }
61
114
 
62
- return { productDir, manifest, stateDir: path.join(productDir, ".testkit") };
115
+ /**
116
+ * Resolve the custom k6 binary for DAL tests. Returns the absolute path.
117
+ */
118
+ export function resolveDalBinary(config) {
119
+ const { productDir, manifest } = config;
120
+ const rel = manifest.testkit.dal?.k6Binary;
121
+ if (!rel) {
122
+ throw new Error("testkit.dal.k6Binary is required for DAL tests");
123
+ }
124
+ const abs = path.resolve(productDir, rel);
125
+ if (!fs.existsSync(abs)) {
126
+ throw new Error(`DAL k6 binary not found: ${abs}`);
127
+ }
128
+ return abs;
63
129
  }
64
130
 
65
- function resolveProductDir(product, cwd, explicitDir) {
131
+ // ── Directory resolution ────────────────────────────────────────────────
132
+
133
+ function resolveProductDir(cwd, explicitDir) {
66
134
  if (explicitDir) {
67
135
  const p = path.resolve(cwd, explicitDir);
68
136
  if (fs.existsSync(path.join(p, "runner.manifest.json"))) return p;
69
137
  throw new Error(`No runner.manifest.json in ${p}`);
70
138
  }
71
139
 
72
- // Try ./bourne/runner.manifest.json (workspace root)
73
- const sibling = path.join(cwd, product);
74
- if (fs.existsSync(path.join(sibling, "runner.manifest.json"))) return sibling;
75
-
76
- // Try ./runner.manifest.json (already in product dir)
77
- const here = path.join(cwd, "runner.manifest.json");
78
- if (fs.existsSync(here)) {
79
- const m = JSON.parse(fs.readFileSync(here, "utf8"));
80
- if (m.product === product) return cwd;
81
- }
140
+ // Check cwd
141
+ if (fs.existsSync(path.join(cwd, "runner.manifest.json"))) return cwd;
82
142
 
83
143
  throw new Error(
84
- `Could not find runner.manifest.json for "${product}". ` +
85
- `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.`
86
146
  );
87
147
  }
148
+
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
+ }
157
+
158
+ // ── Per-service validation ──────────────────────────────────────────────
159
+
160
+ function validateService(name, svc, manifestPath) {
161
+ const errors = [];
162
+ const ctx = `service "${name}" in ${manifestPath}`;
163
+
164
+ if (!isObject(svc.testkit)) {
165
+ errors.push(`${ctx}: testkit is required and must be an object`);
166
+ } else {
167
+ const tk = svc.testkit;
168
+
169
+ if (!isObject(tk.neon)) {
170
+ errors.push(`${ctx}: testkit.neon is required`);
171
+ } else {
172
+ requireString(errors, tk.neon, `${ctx}: testkit.neon.projectId`, "projectId");
173
+ requireString(errors, tk.neon, `${ctx}: testkit.neon.dbName`, "dbName");
174
+ }
175
+
176
+ if (!isObject(tk.fly)) {
177
+ errors.push(`${ctx}: testkit.fly is required`);
178
+ } else {
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");
182
+ }
183
+
184
+ if (tk.k6 !== undefined && !isObject(tk.k6)) {
185
+ errors.push(`${ctx}: testkit.k6 must be an object`);
186
+ }
187
+
188
+ if (tk.dal !== undefined) {
189
+ if (!isObject(tk.dal)) {
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");
193
+ }
194
+ }
195
+
196
+ if (svc.suites?.dal && !tk.dal) {
197
+ errors.push(`${ctx}: testkit.dal is required when suites.dal exists`);
198
+ }
199
+ }
200
+
201
+ if (!isObject(svc.suites)) {
202
+ errors.push(`${ctx}: suites is required and must be an object`);
203
+ } else {
204
+ for (const type of Object.keys(svc.suites)) {
205
+ const arr = svc.suites[type];
206
+ if (!Array.isArray(arr)) {
207
+ errors.push(`${ctx}: suites.${type} must be an array`);
208
+ continue;
209
+ }
210
+ for (let i = 0; i < arr.length; i++) {
211
+ const suite = arr[i];
212
+ const prefix = `${ctx}: suites.${type}[${i}]`;
213
+ requireString(errors, suite, `${prefix}.name`, "name");
214
+ requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
215
+ }
216
+ }
217
+ }
218
+
219
+ if (errors.length) throw new Error(errors.join("\n"));
220
+ }
221
+
222
+ // ── Helpers ─────────────────────────────────────────────────────────────
223
+
224
+ function isObject(v) {
225
+ return v !== null && typeof v === "object" && !Array.isArray(v);
226
+ }
227
+
228
+ function requireString(errors, obj, msg, key) {
229
+ if (typeof obj[key] !== "string" || obj[key].length === 0) {
230
+ errors.push(`${msg} is required`);
231
+ }
232
+ }
233
+
234
+ function optionalStringArray(errors, obj, msg, key) {
235
+ const val = obj[key];
236
+ if (val === undefined) return;
237
+ if (!Array.isArray(val) || !val.every(v => typeof v === "string")) {
238
+ errors.push(`${msg} must be a string[]`);
239
+ }
240
+ }
241
+
242
+ function requireNonEmptyStringArray(errors, obj, msg, key) {
243
+ const val = obj[key];
244
+ if (!Array.isArray(val) || val.length === 0 || !val.every(v => typeof v === "string")) {
245
+ errors.push(`${msg} is required and must be a non-empty string[]`);
246
+ }
247
+ }
package/lib/runner.mjs CHANGED
@@ -2,6 +2,42 @@ 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"]);
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
+ }
5
41
 
6
42
  /**
7
43
  * Build the Docker image and push to Fly registry.
@@ -84,7 +120,7 @@ export async function destroy(config) {
84
120
  }
85
121
 
86
122
  /**
87
- * Run k6 test files with the correct env flags.
123
+ * Run k6 test files against the Fly machine (HTTP tests).
88
124
  */
89
125
  export async function runTests(config, files) {
90
126
  const { productDir, stateDir, manifest } = config;
@@ -108,7 +144,36 @@ export async function runTests(config, files) {
108
144
  await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
109
145
  } catch (err) {
110
146
  failed = true;
111
- // Continue running remaining files — report all failures, don't stop at first
147
+ }
148
+ }
149
+ return { failed };
150
+ }
151
+
152
+ /**
153
+ * Run k6 DAL test files directly against Neon (no Fly machine).
154
+ */
155
+ export async function runDalTests(config, files) {
156
+ const { productDir, stateDir, manifest } = config;
157
+ const tk = manifest.testkit;
158
+ const k6Binary = resolveDalBinary(config);
159
+
160
+ // Read DATABASE_URL from neon state
161
+ const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
162
+
163
+ // Build -e flags
164
+ const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
165
+ for (const key of tk.dal?.secrets || []) {
166
+ envFlags.push(`-e ${key}=${process.env[key]}`);
167
+ }
168
+ const envStr = envFlags.join(" ");
169
+
170
+ let failed = false;
171
+ for (const file of files) {
172
+ const absFile = path.join(productDir, file);
173
+ try {
174
+ await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
175
+ } catch (err) {
176
+ failed = true;
112
177
  }
113
178
  }
114
179
  return { failed };
@@ -125,47 +190,70 @@ export function showStatus(config) {
125
190
  }
126
191
  for (const file of fs.readdirSync(stateDir)) {
127
192
  if (file === "fly-env.sh") continue;
128
- 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();
129
196
  console.log(` ${file}: ${val}`);
130
197
  }
131
198
  }
132
199
 
133
200
  /**
134
- * Main entry: build neon fly k6 → stop.
201
+ * Run a single service: orchestrate HTTP and/or DAL test flows.
202
+ * Returns true if any tests failed.
135
203
  */
136
- export async function run(config, suiteType, suiteNames, opts) {
204
+ async function runService(config, suiteType, suiteNames, opts) {
137
205
  const { manifest, stateDir } = config;
138
206
 
139
- // Collect test files from manifest
207
+ // Collect test files from manifest, partitioned by flow
140
208
  const types = suiteType === "all"
141
209
  ? Object.keys(manifest.suites)
142
210
  : [suiteType === "int" ? "integration" : suiteType];
143
211
 
144
- let files = [];
212
+ let httpFiles = [];
213
+ let dalFiles = [];
145
214
  for (const type of types) {
146
215
  const suites = manifest.suites[type] || [];
147
216
  for (const suite of suites) {
148
217
  if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
149
- files.push(...suite.files);
218
+ if (type === "dal") {
219
+ dalFiles.push(...suite.files);
220
+ } else {
221
+ httpFiles.push(...suite.files);
222
+ }
150
223
  }
151
224
  }
152
225
 
153
- if (!files.length) {
154
- throw new Error(`No test files found for type="${suiteType}" suites=${suiteNames.join(",") || "all"}`);
226
+ if (!httpFiles.length && !dalFiles.length) {
227
+ console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
228
+ return false;
155
229
  }
156
230
 
157
231
  fs.mkdirSync(stateDir, { recursive: true });
158
232
 
159
- if (opts.build) await build(config);
160
- await neonUp(config);
161
- await flyUp(config);
233
+ let failed = false;
234
+ let neonReady = false;
235
+
236
+ // HTTP flow: build → neon → fly → run → fly down
237
+ if (httpFiles.length) {
238
+ requireFlyToken(config.name);
239
+ if (opts.build) await build(config);
240
+ await neonUp(config);
241
+ neonReady = true;
242
+ await flyUp(config);
243
+ try {
244
+ const result = await runTests(config, httpFiles);
245
+ if (result?.failed) failed = true;
246
+ } finally {
247
+ await flyDown(config);
248
+ }
249
+ }
162
250
 
163
- let result;
164
- try {
165
- result = await runTests(config, files);
166
- } finally {
167
- await flyDown(config);
251
+ // DAL flow: neon (idempotent) → run DAL tests
252
+ if (dalFiles.length) {
253
+ if (!neonReady) await neonUp(config);
254
+ const result = await runDalTests(config, dalFiles);
255
+ if (result?.failed) failed = true;
168
256
  }
169
257
 
170
- if (result?.failed) process.exit(1);
258
+ return failed;
171
259
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
+ "description": "CLI for running k6 tests against real, ephemeral infrastructure",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "testkit": "bin/testkit.mjs"