@elench/testkit 0.1.10 → 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,150 +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");
179
- if (tk.neon.reset !== undefined && typeof tk.neon.reset !== "boolean") {
180
- errors.push(`${ctx}: testkit.neon.reset must be a boolean`);
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`);
181
254
  }
182
255
  }
256
+ }
183
257
 
184
- if (!isObject(tk.fly)) {
185
- errors.push(`${ctx}: testkit.fly is required`);
186
- } else {
187
- requireString(errors, tk.fly, `${ctx}: testkit.fly.app`, "app");
188
- requireString(errors, tk.fly, `${ctx}: testkit.fly.org`, "org");
189
- 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
+ );
190
264
  }
265
+ }
191
266
 
192
- if (tk.migrate !== undefined) {
193
- if (!isObject(tk.migrate)) {
194
- errors.push(`${ctx}: testkit.migrate must be an object`);
195
- } else {
196
- requireString(errors, tk.migrate, `${ctx}: testkit.migrate.cmd`, "cmd");
197
- }
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
+ );
198
273
  }
274
+ }
199
275
 
200
- if (tk.k6 !== undefined && !isObject(tk.k6)) {
201
- 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
+ );
202
282
  }
283
+ }
284
+ }
203
285
 
204
- if (tk.dal !== undefined && !isObject(tk.dal)) {
205
- errors.push(`${ctx}: testkit.dal must be an object`);
206
- }
286
+ function validateServiceConfig(name, service, configPath) {
287
+ if (!isObject(service)) {
288
+ throw new Error(`Service "${name}" in ${configPath} must be an object`);
289
+ }
207
290
 
208
- if (tk.depends !== undefined) {
209
- if (!Array.isArray(tk.depends)) {
210
- errors.push(`${ctx}: testkit.depends must be an array`);
211
- } else {
212
- for (let i = 0; i < tk.depends.length; i++) {
213
- const dep = tk.depends[i];
214
- const dp = `${ctx}: testkit.depends[${i}]`;
215
- if (!isObject(dep)) {
216
- errors.push(`${dp} must be an object`);
217
- continue;
218
- }
219
- requireString(errors, dep, `${dp}.name`, "name");
220
- requireString(errors, dep, `${dp}.dockerfile`, "dockerfile");
221
- if (!isObject(dep.fly)) {
222
- errors.push(`${dp}.fly is required and must be an object`);
223
- } else {
224
- requireString(errors, dep.fly, `${dp}.fly.app`, "app");
225
- requireString(errors, dep.fly, `${dp}.fly.org`, "org");
226
- optionalStringArray(errors, dep.fly, `${dp}.fly.secrets`, "secrets");
227
- }
228
- }
229
- }
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`);
230
294
  }
231
295
  }
232
296
 
233
- if (!isObject(svc.suites)) {
234
- errors.push(`${ctx}: suites is required and must be an object`);
235
- } else {
236
- for (const type of Object.keys(svc.suites)) {
237
- const arr = svc.suites[type];
238
- if (!Array.isArray(arr)) {
239
- errors.push(`${ctx}: suites.${type} must be an array`);
240
- continue;
241
- }
242
- for (let i = 0; i < arr.length; i++) {
243
- const suite = arr[i];
244
- const prefix = `${ctx}: suites.${type}[${i}]`;
245
- requireString(errors, suite, `${prefix}.name`, "name");
246
- requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
247
- if (suite.pre !== undefined && (typeof suite.pre !== "string" || suite.pre.length === 0)) {
248
- errors.push(`${prefix}.pre must be a non-empty string (shell command)`);
249
- }
250
- }
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`);
251
312
  }
252
313
  }
253
314
 
254
- if (errors.length) throw new Error(errors.join("\n"));
255
- }
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
+ }
256
324
 
257
- // ── 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
+ }
258
334
 
259
- function isObject(v) {
260
- 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
+ }
261
355
  }
262
356
 
263
- function requireString(errors, obj, msg, key) {
357
+ function requireString(obj, key, label) {
264
358
  if (typeof obj[key] !== "string" || obj[key].length === 0) {
265
- errors.push(`${msg} is required`);
359
+ throw new Error(`${label} must be a non-empty string`);
266
360
  }
267
361
  }
268
362
 
269
- function optionalStringArray(errors, obj, msg, key) {
270
- const val = obj[key];
271
- if (val === undefined) return;
272
- if (!Array.isArray(val) || !val.every(v => typeof v === "string")) {
273
- errors.push(`${msg} must be a string[]`);
274
- }
363
+ function isDalSuiteType(suite, runnerService, suitesForType) {
364
+ if (suite.framework && suite.framework !== "k6") return false;
365
+ return suitesForType === runnerService.suites.dal;
275
366
  }
276
367
 
277
- function requireNonEmptyStringArray(errors, obj, msg, key) {
278
- const val = obj[key];
279
- if (!Array.isArray(val) || val.length === 0 || !val.every(v => typeof v === "string")) {
280
- errors.push(`${msg} is required and must be a non-empty string[]`);
281
- }
368
+ function isObject(value) {
369
+ return value !== null && typeof value === "object" && !Array.isArray(value);
282
370
  }