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