@elench/testkit 0.1.7 → 0.1.9

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/fly-up.sh CHANGED
@@ -31,8 +31,8 @@ DATABASE_URL=$(cat "$STATE_DIR/database_url")
31
31
  DB_URL_ENV_NAME="${DB_URL_ENV_NAME:-DATABASE_URL}"
32
32
  FLY_PORT="${FLY_PORT:-443:3000/tcp:tls:http}"
33
33
 
34
- # Load target-specific env flags (sets FLY_ENV_FLAGS)
35
- FLY_ENV_FLAGS=""
34
+ # Load target-specific env args (sets FLY_ENV_ARGS array)
35
+ FLY_ENV_ARGS=()
36
36
  if [ -n "$FLY_ENV_FILE" ] && [ -f "$FLY_ENV_FILE" ]; then
37
37
  # shellcheck disable=SC1090
38
38
  source "$FLY_ENV_FILE"
@@ -65,17 +65,16 @@ fi
65
65
  if [ -n "$MACHINE_ID" ]; then
66
66
  echo "Reusing machine $MACHINE_ID — updating config"
67
67
 
68
- # Build update flags: always update DATABASE_URL + target env
69
- UPDATE_FLAGS="--env $DB_URL_ENV_NAME=$DATABASE_URL $FLY_ENV_FLAGS"
68
+ # Build update args array: always update DATABASE_URL + target env
69
+ UPDATE_ARGS=(--env "$DB_URL_ENV_NAME=$DATABASE_URL" "${FLY_ENV_ARGS[@]}")
70
70
 
71
71
  # Only update image if one was provided (--build was used)
72
72
  if [ -n "$FLY_IMAGE" ]; then
73
- UPDATE_FLAGS="$UPDATE_FLAGS --image $FLY_IMAGE"
73
+ UPDATE_ARGS+=(--image "$FLY_IMAGE")
74
74
  fi
75
75
 
76
- # shellcheck disable=SC2086
77
76
  fly machines update "$MACHINE_ID" --app "$FLY_APP" \
78
- $UPDATE_FLAGS \
77
+ "${UPDATE_ARGS[@]}" \
79
78
  --yes 2>&1
80
79
 
81
80
  # Start if not already running (update may auto-restart)
@@ -92,14 +91,13 @@ if [ -z "$MACHINE_ID" ]; then
92
91
 
93
92
  echo "Creating Fly machine (app: $FLY_APP, region: $FLY_REGION)"
94
93
 
95
- # shellcheck disable=SC2086
96
94
  if ! RESULT=$(fly machines run "$FLY_IMAGE" \
97
95
  --app "$FLY_APP" \
98
96
  --region "$FLY_REGION" \
99
97
  --vm-memory 512 \
100
98
  --autostop=stop \
101
99
  --env "$DB_URL_ENV_NAME=$DATABASE_URL" \
102
- $FLY_ENV_FLAGS \
100
+ "${FLY_ENV_ARGS[@]}" \
103
101
  --port "$FLY_PORT" \
104
102
  2>&1); then
105
103
  echo "ERROR: fly machines run failed:"
package/lib/config.mjs CHANGED
@@ -87,6 +87,14 @@ export function loadConfigs(opts = {}) {
87
87
  throw new Error(`Secret "${key}" (service "${name}") not found in env or .env`);
88
88
  }
89
89
  }
90
+ // Check dependent secrets
91
+ for (const dep of tk.depends || []) {
92
+ for (const key of dep.fly?.secrets || []) {
93
+ if (!process.env[key]) {
94
+ throw new Error(`Secret "${key}" (service "${name}", dep "${dep.name}") not found in env or .env`);
95
+ }
96
+ }
97
+ }
90
98
 
91
99
  // Each service gets its own state dir: .testkit/<service>/
92
100
  const stateDir = entries.length === 1
@@ -193,6 +201,30 @@ function validateService(name, svc, manifestPath) {
193
201
  if (tk.dal !== undefined && !isObject(tk.dal)) {
194
202
  errors.push(`${ctx}: testkit.dal must be an object`);
195
203
  }
204
+
205
+ if (tk.depends !== undefined) {
206
+ if (!Array.isArray(tk.depends)) {
207
+ errors.push(`${ctx}: testkit.depends must be an array`);
208
+ } else {
209
+ for (let i = 0; i < tk.depends.length; i++) {
210
+ const dep = tk.depends[i];
211
+ const dp = `${ctx}: testkit.depends[${i}]`;
212
+ if (!isObject(dep)) {
213
+ errors.push(`${dp} must be an object`);
214
+ continue;
215
+ }
216
+ requireString(errors, dep, `${dp}.name`, "name");
217
+ requireString(errors, dep, `${dp}.dockerfile`, "dockerfile");
218
+ if (!isObject(dep.fly)) {
219
+ errors.push(`${dp}.fly is required and must be an object`);
220
+ } else {
221
+ requireString(errors, dep.fly, `${dp}.fly.app`, "app");
222
+ requireString(errors, dep.fly, `${dp}.fly.org`, "org");
223
+ optionalStringArray(errors, dep.fly, `${dp}.fly.secrets`, "secrets");
224
+ }
225
+ }
226
+ }
227
+ }
196
228
  }
197
229
 
198
230
  if (!isObject(svc.suites)) {
package/lib/runner.mjs CHANGED
@@ -75,16 +75,16 @@ export async function flyUp(config) {
75
75
  const { productDir, stateDir, manifest } = config;
76
76
  const tk = manifest.testkit;
77
77
 
78
- // Generate fly-env.sh from manifest
78
+ // Generate fly-env.sh from manifest (bash array for safe quoting)
79
79
  const envLines = [];
80
80
  for (const [k, v] of Object.entries(tk.fly.env || {})) {
81
- envLines.push(` --env ${k}=${v}`);
81
+ envLines.push(` --env "${k}=${v}"`);
82
82
  }
83
83
  for (const k of tk.fly.secrets || []) {
84
- envLines.push(` --env ${k}=$${k}`); // shell substitution at source-time
84
+ envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
85
85
  }
86
86
  const flyEnvPath = path.join(stateDir, "fly-env.sh");
87
- fs.writeFileSync(flyEnvPath, `FLY_ENV_FLAGS="\n${envLines.join("\n")}\n"\n`);
87
+ fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
88
88
 
89
89
  await runScript("fly-up.sh", {
90
90
  FLY_APP: tk.fly.app,
@@ -96,6 +96,69 @@ export async function flyUp(config) {
96
96
  });
97
97
  }
98
98
 
99
+ /**
100
+ * Build a dependent service's Docker image.
101
+ */
102
+ async function buildDep(config, dep) {
103
+ const { productDir, stateDir } = config;
104
+ const depStateDir = path.join(stateDir, "deps", dep.name);
105
+ fs.mkdirSync(depStateDir, { recursive: true });
106
+ await runScript("fly-build.sh", {
107
+ FLY_APP: dep.fly.app,
108
+ FLY_ORG: dep.fly.org,
109
+ API_DIR: productDir,
110
+ DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
111
+ STATE_DIR: depStateDir,
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Deploy a dependent service's Fly machine.
117
+ * Copies database_url from primary, generates fly-env.sh, calls fly-up.sh.
118
+ */
119
+ async function flyUpDep(config, dep) {
120
+ const { stateDir } = config;
121
+ const depStateDir = path.join(stateDir, "deps", dep.name);
122
+ fs.mkdirSync(depStateDir, { recursive: true });
123
+
124
+ // Copy database_url from primary state dir
125
+ const primaryDbUrl = path.join(stateDir, "database_url");
126
+ if (fs.existsSync(primaryDbUrl)) {
127
+ fs.copyFileSync(primaryDbUrl, path.join(depStateDir, "database_url"));
128
+ }
129
+
130
+ // Generate fly-env.sh for dependent (bash array for safe quoting)
131
+ const envLines = [];
132
+ for (const [k, v] of Object.entries(dep.fly.env || {})) {
133
+ envLines.push(` --env "${k}=${v}"`);
134
+ }
135
+ for (const k of dep.fly.secrets || []) {
136
+ envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
137
+ }
138
+ const flyEnvPath = path.join(depStateDir, "fly-env.sh");
139
+ fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
140
+
141
+ await runScript("fly-up.sh", {
142
+ FLY_APP: dep.fly.app,
143
+ FLY_ORG: dep.fly.org,
144
+ FLY_REGION: dep.fly.region || "lhr",
145
+ FLY_PORT: dep.fly.port,
146
+ FLY_ENV_FILE: flyEnvPath,
147
+ STATE_DIR: depStateDir,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Stop a dependent service's Fly machine.
153
+ */
154
+ async function flyDownDep(config, dep) {
155
+ const depStateDir = path.join(config.stateDir, "deps", dep.name);
156
+ await runScript("fly-down.sh", {
157
+ FLY_APP: dep.fly.app,
158
+ STATE_DIR: depStateDir,
159
+ });
160
+ }
161
+
99
162
  /**
100
163
  * Stop the Fly machine (preserved for reuse).
101
164
  */
@@ -109,10 +172,20 @@ export async function flyDown(config) {
109
172
 
110
173
  /**
111
174
  * Destroy machine + neon branch + state.
175
+ * Destroys dependent machines first, then primary.
112
176
  */
113
177
  export async function destroy(config) {
114
178
  const { stateDir, manifest } = config;
115
179
  const tk = manifest.testkit;
180
+
181
+ // Destroy dependents first
182
+ for (const dep of tk.depends || []) {
183
+ const depStateDir = path.join(stateDir, "deps", dep.name);
184
+ if (fs.existsSync(depStateDir)) {
185
+ await runScript("fly-destroy.sh", { FLY_APP: dep.fly.app, STATE_DIR: depStateDir });
186
+ }
187
+ }
188
+
116
189
  await runScript("fly-destroy.sh", { FLY_APP: tk.fly.app, STATE_DIR: stateDir });
117
190
  await runScript("neon-down.sh", {
118
191
  NEON_PROJECT_ID: tk.neon.projectId,
@@ -257,6 +330,23 @@ export function showStatus(config) {
257
330
  const val = fs.readFileSync(filePath, "utf8").trim();
258
331
  console.log(` ${file}: ${val}`);
259
332
  }
333
+
334
+ // Show dependent state dirs
335
+ const depsDir = path.join(stateDir, "deps");
336
+ if (fs.existsSync(depsDir)) {
337
+ for (const depName of fs.readdirSync(depsDir)) {
338
+ const depDir = path.join(depsDir, depName);
339
+ if (!fs.statSync(depDir).isDirectory()) continue;
340
+ console.log(` ── dep: ${depName} ──`);
341
+ for (const file of fs.readdirSync(depDir)) {
342
+ if (file === "fly-env.sh") continue;
343
+ const filePath = path.join(depDir, file);
344
+ if (fs.statSync(filePath).isDirectory()) continue;
345
+ const val = fs.readFileSync(filePath, "utf8").trim();
346
+ console.log(` ${file}: ${val}`);
347
+ }
348
+ }
349
+ }
260
350
  }
261
351
 
262
352
  /**
@@ -317,11 +407,27 @@ async function runService(config, suiteType, suiteNames, opts) {
317
407
  // HTTP flow: build → neon → migrate → fly → run → fly down
318
408
  if (httpFiles.length) {
319
409
  requireFlyToken(config.name);
320
- if (opts.build) await build(config);
410
+ const deps = manifest.testkit.depends || [];
411
+
412
+ // Phase 1: Build primary + deps in parallel
413
+ if (opts.build) {
414
+ await Promise.all([
415
+ build(config),
416
+ ...deps.map(dep => buildDep(config, dep)),
417
+ ]);
418
+ }
419
+
420
+ // Phase 2: Database (unchanged)
321
421
  await neonUp(config);
322
422
  await migrate(config);
323
423
  neonReady = true;
324
- await flyUp(config);
424
+
425
+ // Phase 3: Deploy primary + deps in parallel
426
+ await Promise.all([
427
+ flyUp(config),
428
+ ...deps.map(dep => flyUpDep(config, dep)),
429
+ ]);
430
+
325
431
  try {
326
432
  for (const suite of httpSuites) {
327
433
  await runSuitePre(config, suite);
@@ -329,7 +435,11 @@ async function runService(config, suiteType, suiteNames, opts) {
329
435
  const result = await runTests(config, httpFiles);
330
436
  if (result?.failed) failed = true;
331
437
  } finally {
332
- await flyDown(config);
438
+ // Phase 5: Teardown primary + deps in parallel
439
+ await Promise.allSettled([
440
+ flyDown(config),
441
+ ...deps.map(dep => flyDownDep(config, dep)),
442
+ ]);
333
443
  }
334
444
  }
335
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {