@elench/testkit 0.1.9 → 0.1.11

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/lib/config.mjs CHANGED
@@ -2,6 +2,10 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
+ const RUNNER_MANIFEST = "runner.manifest.json";
6
+ const TESTKIT_CONFIG = "testkit.config.json";
7
+ const VALID_FRAMEWORKS = new Set(["k6", "playwright"]);
8
+
5
9
  /**
6
10
  * Parse a .env file into an object. Supports KEY=VALUE, KEY='VALUE', KEY="VALUE".
7
11
  * Skips comments (#) and blank lines.
@@ -16,8 +20,10 @@ export function parseDotenv(filePath) {
16
20
  if (eq === -1) continue;
17
21
  const key = trimmed.slice(0, eq).trim();
18
22
  let val = trimmed.slice(eq + 1).trim();
19
- if ((val.startsWith("'") && val.endsWith("'")) ||
20
- (val.startsWith('"') && val.endsWith('"'))) {
23
+ if (
24
+ (val.startsWith("'") && val.endsWith("'")) ||
25
+ (val.startsWith('"') && val.endsWith('"'))
26
+ ) {
21
27
  val = val.slice(1, -1);
22
28
  }
23
29
  env[key] = val;
@@ -25,105 +31,62 @@ export function parseDotenv(filePath) {
25
31
  return env;
26
32
  }
27
33
 
28
- /**
29
- * Read the manifest and return the service names.
30
- * Used by the CLI to resolve ambiguous positional args.
31
- */
32
34
  export function getServiceNames(cwd) {
33
35
  const dir = cwd || process.cwd();
34
- const manifestPath = path.join(dir, "testkit.manifest.json");
35
- if (!fs.existsSync(manifestPath)) return [];
36
- const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
37
- if (!isObject(m.services)) return [];
38
- return Object.keys(m.services);
36
+ const runnerPath = path.join(dir, RUNNER_MANIFEST);
37
+ if (!fs.existsSync(runnerPath)) return [];
38
+ const runner = JSON.parse(fs.readFileSync(runnerPath, "utf8"));
39
+ if (!isObject(runner.services)) return [];
40
+ return Object.keys(runner.services);
39
41
  }
40
42
 
41
- /**
42
- * Load configs for all (or one) service from the manifest.
43
- * Returns an array of { name, productDir, stateDir, manifest } objects.
44
- *
45
- * opts.service — filter to a single service name
46
- * opts.dir — explicit product directory
47
- */
48
43
  export function loadConfigs(opts = {}) {
49
- const cwd = process.cwd();
50
- const productDir = resolveProductDir(cwd, opts.dir);
51
- const manifestPath = path.join(productDir, "testkit.manifest.json");
52
- const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
53
-
54
- if (!isObject(raw.services)) {
55
- throw new Error(`Manifest must have a "services" object (${manifestPath})`);
56
- }
44
+ const productDir = resolveProductDir(process.cwd(), opts.dir);
45
+ const runner = loadRunnerManifest(productDir);
46
+ const config = loadTestkitConfig(productDir);
47
+ validateConfigCoverage(runner, config);
57
48
 
58
- // Load product .env into process.env (product wins on conflict)
59
49
  const dotenv = parseDotenv(path.join(productDir, ".env"));
60
50
  Object.assign(process.env, dotenv);
61
51
 
62
- if (!process.env.NEON_API_KEY) {
63
- throw new Error(
64
- `NEON_API_KEY not found. Set it in your shell environment, .envrc, or .env`
65
- );
66
- }
67
-
68
- const entries = Object.entries(raw.services);
69
-
70
- // Filter by service name if requested
52
+ const serviceEntries = Object.entries(runner.services);
71
53
  const filtered = opts.service
72
- ? entries.filter(([name]) => name === opts.service)
73
- : entries;
54
+ ? serviceEntries.filter(([name]) => name === opts.service)
55
+ : serviceEntries;
74
56
 
75
57
  if (opts.service && filtered.length === 0) {
76
- const available = entries.map(([n]) => n).join(", ");
58
+ const available = serviceEntries.map(([name]) => name).join(", ");
77
59
  throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
78
60
  }
79
61
 
80
- return filtered.map(([name, svc]) => {
81
- validateService(name, svc, manifestPath);
82
-
83
- // Check service secrets are available
84
- const tk = svc.testkit;
85
- for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || []), ...(tk.dal?.secrets || [])]) {
86
- if (!process.env[key]) {
87
- throw new Error(`Secret "${key}" (service "${name}") not found in env or .env`);
88
- }
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
- }
62
+ return filtered.map(([name, runnerService]) => {
63
+ const serviceConfig = config.services[name];
64
+ if (!serviceConfig) {
65
+ throw new Error(
66
+ `Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
67
+ );
97
68
  }
98
69
 
99
- // Each service gets its own state dir: .testkit/<service>/
100
- const stateDir = entries.length === 1
101
- ? path.join(productDir, ".testkit")
102
- : path.join(productDir, ".testkit", name);
70
+ validateMergedService(name, runnerService, serviceConfig, productDir);
103
71
 
104
72
  return {
105
73
  name,
106
74
  productDir,
107
- stateDir,
108
- manifest: { testkit: tk, suites: svc.suites },
75
+ stateDir: path.join(productDir, ".testkit", name),
76
+ suites: runnerService.suites,
77
+ testkit: serviceConfig,
109
78
  };
110
79
  });
111
80
  }
112
81
 
113
- /**
114
- * Require FLY_API_TOKEN. Call this only when HTTP tests need Fly infrastructure.
115
- */
116
- export function requireFlyToken(serviceName) {
117
- if (!process.env.FLY_API_TOKEN) {
82
+ export function requireNeonApiKey() {
83
+ if (!process.env.NEON_API_KEY) {
118
84
  throw new Error(
119
- `FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or .env`
85
+ "NEON_API_KEY not found. Set it in your shell environment, .envrc, or .env"
120
86
  );
121
87
  }
122
88
  }
123
89
 
124
- /**
125
- * Resolve the bundled k6-sql binary for DAL tests. Returns the absolute path.
126
- */
127
90
  export function resolveDalBinary() {
128
91
  const thisFile = fileURLToPath(import.meta.url);
129
92
  const abs = path.resolve(path.dirname(thisFile), "..", "vendor", "k6");
@@ -133,147 +96,275 @@ export function resolveDalBinary() {
133
96
  return abs;
134
97
  }
135
98
 
136
- // ── Directory resolution ────────────────────────────────────────────────
99
+ export function resolveServiceCwd(productDir, maybeRelative) {
100
+ return path.resolve(productDir, maybeRelative || ".");
101
+ }
102
+
103
+ function loadRunnerManifest(productDir) {
104
+ const manifestPath = path.join(productDir, RUNNER_MANIFEST);
105
+ const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
106
+
107
+ if (!isObject(raw.services)) {
108
+ throw new Error(`${RUNNER_MANIFEST} must have a "services" object (${manifestPath})`);
109
+ }
110
+
111
+ for (const [serviceName, service] of Object.entries(raw.services)) {
112
+ if (!isObject(service)) {
113
+ throw new Error(`Service "${serviceName}" in ${RUNNER_MANIFEST} must be an object`);
114
+ }
115
+ if (!isObject(service.suites)) {
116
+ throw new Error(`Service "${serviceName}" in ${RUNNER_MANIFEST} must define suites`);
117
+ }
118
+
119
+ for (const [suiteType, suites] of Object.entries(service.suites)) {
120
+ if (!Array.isArray(suites)) {
121
+ throw new Error(
122
+ `Service "${serviceName}" suite type "${suiteType}" must be an array`
123
+ );
124
+ }
125
+
126
+ const seenNames = new Set();
127
+ for (const suite of suites) {
128
+ if (!isObject(suite)) {
129
+ throw new Error(
130
+ `Service "${serviceName}" suite type "${suiteType}" contains a non-object suite`
131
+ );
132
+ }
133
+ if (typeof suite.name !== "string" || !suite.name.length) {
134
+ throw new Error(
135
+ `Service "${serviceName}" suite type "${suiteType}" has a suite with no name`
136
+ );
137
+ }
138
+ if (seenNames.has(suite.name)) {
139
+ throw new Error(
140
+ `Service "${serviceName}" suite type "${suiteType}" has duplicate suite name "${suite.name}"`
141
+ );
142
+ }
143
+ seenNames.add(suite.name);
144
+
145
+ if (!Array.isArray(suite.files) || suite.files.length === 0) {
146
+ throw new Error(
147
+ `Service "${serviceName}" suite "${suite.name}" must define one or more files`
148
+ );
149
+ }
150
+ for (const file of suite.files) {
151
+ if (typeof file !== "string" || !file.length) {
152
+ throw new Error(
153
+ `Service "${serviceName}" suite "${suite.name}" contains an invalid file entry`
154
+ );
155
+ }
156
+ }
157
+
158
+ const framework = suite.framework || "k6";
159
+ if (!VALID_FRAMEWORKS.has(framework)) {
160
+ throw new Error(
161
+ `Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
162
+ );
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ return raw;
169
+ }
170
+
171
+ function loadTestkitConfig(productDir) {
172
+ const configPath = path.join(productDir, TESTKIT_CONFIG);
173
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
174
+
175
+ if (!isObject(raw.services)) {
176
+ throw new Error(`${TESTKIT_CONFIG} must have a "services" object (${configPath})`);
177
+ }
178
+
179
+ for (const [serviceName, service] of Object.entries(raw.services)) {
180
+ validateServiceConfig(serviceName, service, configPath);
181
+ }
182
+
183
+ return raw;
184
+ }
185
+
186
+ function validateConfigCoverage(runner, config) {
187
+ for (const serviceName of Object.keys(config.services)) {
188
+ if (!runner.services[serviceName]) {
189
+ throw new Error(
190
+ `Service "${serviceName}" exists in ${TESTKIT_CONFIG} but not in ${RUNNER_MANIFEST}`
191
+ );
192
+ }
193
+
194
+ for (const depName of config.services[serviceName].dependsOn || []) {
195
+ if (!config.services[depName]) {
196
+ throw new Error(
197
+ `Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${TESTKIT_CONFIG}`
198
+ );
199
+ }
200
+ if (!runner.services[depName]) {
201
+ throw new Error(
202
+ `Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${RUNNER_MANIFEST}`
203
+ );
204
+ }
205
+ }
206
+ }
207
+ }
137
208
 
138
209
  function resolveProductDir(cwd, explicitDir) {
139
210
  if (explicitDir) {
140
- const p = path.resolve(cwd, explicitDir);
141
- if (fs.existsSync(path.join(p, "testkit.manifest.json"))) return p;
142
- throw new Error(`No testkit.manifest.json in ${p}`);
211
+ const dir = path.resolve(cwd, explicitDir);
212
+ ensureProductFiles(dir);
213
+ return dir;
143
214
  }
144
215
 
145
- // Check cwd
146
- if (fs.existsSync(path.join(cwd, "testkit.manifest.json"))) return cwd;
216
+ ensureProductFiles(cwd);
217
+ return cwd;
218
+ }
147
219
 
148
- throw new Error(
149
- `No testkit.manifest.json in current directory. ` +
150
- `Either cd into a product directory or use --dir.`
220
+ function ensureProductFiles(dir) {
221
+ const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
222
+ (file) => !fs.existsSync(path.join(dir, file))
151
223
  );
224
+ if (missing.length > 0) {
225
+ throw new Error(
226
+ `Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
227
+ );
228
+ }
152
229
  }
153
230
 
154
- /**
155
- * Check if the given name (relative to cwd) is a sibling directory with a manifest.
156
- * Used by the CLI to distinguish dir args from service/type args.
157
- */
158
231
  export function isSiblingProduct(name) {
159
232
  const candidate = path.join(process.cwd(), name);
160
- return fs.existsSync(path.join(candidate, "testkit.manifest.json"));
233
+ return (
234
+ fs.existsSync(path.join(candidate, RUNNER_MANIFEST)) &&
235
+ fs.existsSync(path.join(candidate, TESTKIT_CONFIG))
236
+ );
161
237
  }
162
238
 
163
- // ── Per-service validation ──────────────────────────────────────────────
164
-
165
- function validateService(name, svc, manifestPath) {
166
- const errors = [];
167
- const ctx = `service "${name}" in ${manifestPath}`;
239
+ function validateMergedService(name, runnerService, serviceConfig, productDir) {
240
+ const usesLocalExecution = Object.values(runnerService.suites).some((suites) =>
241
+ suites.some((suite) => suite.framework !== "k6" || !isDalSuiteType(suite, runnerService, suites))
242
+ );
168
243
 
169
- if (!isObject(svc.testkit)) {
170
- errors.push(`${ctx}: testkit is required and must be an object`);
171
- } else {
172
- const tk = svc.testkit;
244
+ if (usesLocalExecution && !isObject(serviceConfig.local)) {
245
+ throw new Error(
246
+ `Service "${name}" defines non-DAL suites but has no local runtime in ${TESTKIT_CONFIG}`
247
+ );
248
+ }
173
249
 
174
- if (!isObject(tk.neon)) {
175
- errors.push(`${ctx}: testkit.neon is required`);
176
- } else {
177
- requireString(errors, tk.neon, `${ctx}: testkit.neon.projectId`, "projectId");
178
- requireString(errors, tk.neon, `${ctx}: testkit.neon.dbName`, "dbName");
250
+ if (serviceConfig.dependsOn) {
251
+ for (const dep of serviceConfig.dependsOn) {
252
+ if (dep === name) {
253
+ throw new Error(`Service "${name}" cannot depend on itself`);
254
+ }
179
255
  }
256
+ }
180
257
 
181
- if (!isObject(tk.fly)) {
182
- errors.push(`${ctx}: testkit.fly is required`);
183
- } else {
184
- requireString(errors, tk.fly, `${ctx}: testkit.fly.app`, "app");
185
- requireString(errors, tk.fly, `${ctx}: testkit.fly.org`, "org");
186
- optionalStringArray(errors, tk.fly, `${ctx}: testkit.fly.secrets`, "secrets");
258
+ if (serviceConfig.local?.cwd) {
259
+ const cwdPath = resolveServiceCwd(productDir, serviceConfig.local.cwd);
260
+ if (!fs.existsSync(cwdPath)) {
261
+ throw new Error(
262
+ `Service "${name}" local.cwd does not exist: ${serviceConfig.local.cwd}`
263
+ );
187
264
  }
265
+ }
188
266
 
189
- if (tk.migrate !== undefined) {
190
- if (!isObject(tk.migrate)) {
191
- errors.push(`${ctx}: testkit.migrate must be an object`);
192
- } else {
193
- requireString(errors, tk.migrate, `${ctx}: testkit.migrate.cmd`, "cmd");
194
- }
267
+ if (serviceConfig.migrate?.cwd) {
268
+ const cwdPath = resolveServiceCwd(productDir, serviceConfig.migrate.cwd);
269
+ if (!fs.existsSync(cwdPath)) {
270
+ throw new Error(
271
+ `Service "${name}" migrate.cwd does not exist: ${serviceConfig.migrate.cwd}`
272
+ );
195
273
  }
274
+ }
196
275
 
197
- if (tk.k6 !== undefined && !isObject(tk.k6)) {
198
- errors.push(`${ctx}: testkit.k6 must be an object`);
276
+ if (serviceConfig.seed?.cwd) {
277
+ const cwdPath = resolveServiceCwd(productDir, serviceConfig.seed.cwd);
278
+ if (!fs.existsSync(cwdPath)) {
279
+ throw new Error(
280
+ `Service "${name}" seed.cwd does not exist: ${serviceConfig.seed.cwd}`
281
+ );
199
282
  }
283
+ }
284
+ }
200
285
 
201
- if (tk.dal !== undefined && !isObject(tk.dal)) {
202
- errors.push(`${ctx}: testkit.dal must be an object`);
203
- }
286
+ function validateServiceConfig(name, service, configPath) {
287
+ if (!isObject(service)) {
288
+ throw new Error(`Service "${name}" in ${configPath} must be an object`);
289
+ }
204
290
 
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
- }
291
+ if (service.dependsOn !== undefined) {
292
+ if (!Array.isArray(service.dependsOn) || service.dependsOn.some((v) => typeof v !== "string")) {
293
+ throw new Error(`Service "${name}" dependsOn must be an array of service names`);
227
294
  }
228
295
  }
229
296
 
230
- if (!isObject(svc.suites)) {
231
- errors.push(`${ctx}: suites is required and must be an object`);
232
- } else {
233
- for (const type of Object.keys(svc.suites)) {
234
- const arr = svc.suites[type];
235
- if (!Array.isArray(arr)) {
236
- errors.push(`${ctx}: suites.${type} must be an array`);
237
- continue;
238
- }
239
- for (let i = 0; i < arr.length; i++) {
240
- const suite = arr[i];
241
- const prefix = `${ctx}: suites.${type}[${i}]`;
242
- requireString(errors, suite, `${prefix}.name`, "name");
243
- requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
244
- if (suite.pre !== undefined && (typeof suite.pre !== "string" || suite.pre.length === 0)) {
245
- errors.push(`${prefix}.pre must be a non-empty string (shell command)`);
246
- }
247
- }
297
+ if (service.database !== undefined) {
298
+ if (!isObject(service.database)) {
299
+ throw new Error(`Service "${name}" database must be an object`);
300
+ }
301
+ const db = service.database;
302
+ if (db.provider !== "neon") {
303
+ throw new Error(`Service "${name}" database.provider must be "neon"`);
304
+ }
305
+ requireString(db, "projectId", `Service "${name}" database.projectId`);
306
+ requireString(db, "dbName", `Service "${name}" database.dbName`);
307
+ if (db.branchName !== undefined && typeof db.branchName !== "string") {
308
+ throw new Error(`Service "${name}" database.branchName must be a string`);
309
+ }
310
+ if (db.reset !== undefined && typeof db.reset !== "boolean") {
311
+ throw new Error(`Service "${name}" database.reset must be a boolean`);
248
312
  }
249
313
  }
250
314
 
251
- if (errors.length) throw new Error(errors.join("\n"));
252
- }
315
+ if (service.migrate !== undefined) {
316
+ if (!isObject(service.migrate)) {
317
+ throw new Error(`Service "${name}" migrate must be an object`);
318
+ }
319
+ requireString(service.migrate, "cmd", `Service "${name}" migrate.cmd`);
320
+ if (service.migrate.cwd !== undefined && typeof service.migrate.cwd !== "string") {
321
+ throw new Error(`Service "${name}" migrate.cwd must be a string`);
322
+ }
323
+ }
253
324
 
254
- // ── Helpers ─────────────────────────────────────────────────────────────
325
+ if (service.seed !== undefined) {
326
+ if (!isObject(service.seed)) {
327
+ throw new Error(`Service "${name}" seed must be an object`);
328
+ }
329
+ requireString(service.seed, "cmd", `Service "${name}" seed.cmd`);
330
+ if (service.seed.cwd !== undefined && typeof service.seed.cwd !== "string") {
331
+ throw new Error(`Service "${name}" seed.cwd must be a string`);
332
+ }
333
+ }
255
334
 
256
- function isObject(v) {
257
- return v !== null && typeof v === "object" && !Array.isArray(v);
335
+ if (service.local !== undefined) {
336
+ if (!isObject(service.local)) {
337
+ throw new Error(`Service "${name}" local must be an object`);
338
+ }
339
+ requireString(service.local, "start", `Service "${name}" local.start`);
340
+ requireString(service.local, "baseUrl", `Service "${name}" local.baseUrl`);
341
+ requireString(service.local, "readyUrl", `Service "${name}" local.readyUrl`);
342
+ if (service.local.cwd !== undefined && typeof service.local.cwd !== "string") {
343
+ throw new Error(`Service "${name}" local.cwd must be a string`);
344
+ }
345
+ if (
346
+ service.local.readyTimeoutMs !== undefined &&
347
+ (!Number.isInteger(service.local.readyTimeoutMs) || service.local.readyTimeoutMs <= 0)
348
+ ) {
349
+ throw new Error(`Service "${name}" local.readyTimeoutMs must be a positive integer`);
350
+ }
351
+ if (service.local.env !== undefined && !isObject(service.local.env)) {
352
+ throw new Error(`Service "${name}" local.env must be an object`);
353
+ }
354
+ }
258
355
  }
259
356
 
260
- function requireString(errors, obj, msg, key) {
357
+ function requireString(obj, key, label) {
261
358
  if (typeof obj[key] !== "string" || obj[key].length === 0) {
262
- errors.push(`${msg} is required`);
359
+ throw new Error(`${label} must be a non-empty string`);
263
360
  }
264
361
  }
265
362
 
266
- function optionalStringArray(errors, obj, msg, key) {
267
- const val = obj[key];
268
- if (val === undefined) return;
269
- if (!Array.isArray(val) || !val.every(v => typeof v === "string")) {
270
- errors.push(`${msg} must be a string[]`);
271
- }
363
+ function isDalSuiteType(suite, runnerService, suitesForType) {
364
+ if (suite.framework && suite.framework !== "k6") return false;
365
+ return suitesForType === runnerService.suites.dal;
272
366
  }
273
367
 
274
- function requireNonEmptyStringArray(errors, obj, msg, key) {
275
- const val = obj[key];
276
- if (!Array.isArray(val) || val.length === 0 || !val.every(v => typeof v === "string")) {
277
- errors.push(`${msg} is required and must be a non-empty string[]`);
278
- }
368
+ function isObject(value) {
369
+ return value !== null && typeof value === "object" && !Array.isArray(value);
279
370
  }