@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/README.md +24 -30
- package/lib/cli.mjs +18 -3
- package/lib/config.mjs +264 -176
- package/lib/runner.mjs +488 -375
- package/package.json +4 -3
- package/infra/fly-app-ensure.sh +0 -23
- package/infra/fly-build.sh +0 -55
- package/infra/fly-destroy.sh +0 -21
- package/infra/fly-down.sh +0 -19
- package/infra/fly-up.sh +0 -142
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 (
|
|
20
|
-
|
|
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
|
|
35
|
-
if (!fs.existsSync(
|
|
36
|
-
const
|
|
37
|
-
if (!isObject(
|
|
38
|
-
return Object.keys(
|
|
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
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
73
|
-
:
|
|
54
|
+
? serviceEntries.filter(([name]) => name === opts.service)
|
|
55
|
+
: serviceEntries;
|
|
74
56
|
|
|
75
57
|
if (opts.service && filtered.length === 0) {
|
|
76
|
-
const available =
|
|
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,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
stateDir: path.join(productDir, ".testkit", name),
|
|
76
|
+
suites: runnerService.suites,
|
|
77
|
+
testkit: serviceConfig,
|
|
109
78
|
};
|
|
110
79
|
});
|
|
111
80
|
}
|
|
112
81
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
211
|
+
const dir = path.resolve(cwd, explicitDir);
|
|
212
|
+
ensureProductFiles(dir);
|
|
213
|
+
return dir;
|
|
143
214
|
}
|
|
144
215
|
|
|
145
|
-
|
|
146
|
-
|
|
216
|
+
ensureProductFiles(cwd);
|
|
217
|
+
return cwd;
|
|
218
|
+
}
|
|
147
219
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
233
|
+
return (
|
|
234
|
+
fs.existsSync(path.join(candidate, RUNNER_MANIFEST)) &&
|
|
235
|
+
fs.existsSync(path.join(candidate, TESTKIT_CONFIG))
|
|
236
|
+
);
|
|
161
237
|
}
|
|
162
238
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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(
|
|
357
|
+
function requireString(obj, key, label) {
|
|
264
358
|
if (typeof obj[key] !== "string" || obj[key].length === 0) {
|
|
265
|
-
|
|
359
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
266
360
|
}
|
|
267
361
|
}
|
|
268
362
|
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
278
|
-
|
|
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
|
}
|