@elench/testkit 0.1.2 → 0.1.4
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 +28 -19
- package/infra/neon-up.sh +11 -3
- package/lib/cli.mjs +54 -27
- package/lib/config.mjs +114 -128
- package/lib/runner.mjs +44 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @elench/testkit
|
|
2
2
|
|
|
3
|
-
CLI that reads `
|
|
3
|
+
CLI that reads `testkit.manifest.json` from a product repo, spins up ephemeral infrastructure (Neon DB branch + Fly machine) per service, runs k6 tests, and tears down.
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
@@ -13,41 +13,50 @@ fly auth login
|
|
|
13
13
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Add platform secrets to each product's `.env`:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
NEON_API_KEY='...'
|
|
20
|
+
FLY_API_TOKEN='...'
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Product secrets (`
|
|
23
|
+
Product secrets (`CLERK_SECRET_KEY`, etc.) are also loaded from the same `.env`.
|
|
24
24
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
27
|
-
From the `elench/` workspace root:
|
|
28
|
-
|
|
29
27
|
```bash
|
|
30
|
-
|
|
31
|
-
node testkit/bin/testkit.mjs bourne --build
|
|
28
|
+
cd bourne
|
|
32
29
|
|
|
33
|
-
#
|
|
34
|
-
|
|
30
|
+
# Run all suites
|
|
31
|
+
npx @elench/testkit
|
|
35
32
|
|
|
36
33
|
# Specific type / suite
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
npx @elench/testkit int -s health
|
|
35
|
+
npx @elench/testkit e2e
|
|
36
|
+
|
|
37
|
+
# Specific service (multi-service products)
|
|
38
|
+
npx @elench/testkit avocado_api int -s health
|
|
39
|
+
|
|
40
|
+
# Build from source first
|
|
41
|
+
npx @elench/testkit int --build
|
|
39
42
|
|
|
40
43
|
# Lifecycle
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
npx @elench/testkit status
|
|
45
|
+
npx @elench/testkit destroy
|
|
43
46
|
```
|
|
44
47
|
|
|
45
48
|
## How it works
|
|
46
49
|
|
|
47
|
-
1. **Config** — reads
|
|
48
|
-
2. **Neon** — creates
|
|
49
|
-
3. **Fly** —
|
|
50
|
+
1. **Config** — reads `testkit.manifest.json` for per-service infra config + test suites
|
|
51
|
+
2. **Neon** — discovers or creates a `<service>-test` branch, truncates tables between runs
|
|
52
|
+
3. **Fly** — discovers or creates a machine on the service's test app, updates env vars
|
|
50
53
|
4. **k6** — runs matched test files with `BASE_URL` and `MACHINE_ID` injected
|
|
51
54
|
5. **Cleanup** — stops the Fly machine (preserved for next run)
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
Multi-service products run all services in parallel. Each service gets its own Neon branch and Fly machine.
|
|
57
|
+
|
|
58
|
+
State is persisted in `.testkit/` (or `.testkit/<service>/` for multi-service) so subsequent runs reuse existing infrastructure.
|
|
59
|
+
|
|
60
|
+
## Manifest schema
|
|
61
|
+
|
|
62
|
+
See [testkit-manifest-schema.md](testkit-manifest-schema.md).
|
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
|
-
const manifestPath = path.join(dir, "
|
|
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
|
-
}
|
|
33
|
+
const manifestPath = path.join(dir, "testkit.manifest.json");
|
|
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(
|
|
54
|
-
const manifestPath = path.join(productDir, "
|
|
55
|
-
const
|
|
47
|
+
export function loadConfigs(opts = {}) {
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
const productDir = resolveProductDir(cwd, opts.dir);
|
|
50
|
+
const manifestPath = path.join(productDir, "testkit.manifest.json");
|
|
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
|
-
if (fs.existsSync(path.join(p, "
|
|
112
|
-
throw new Error(`No
|
|
136
|
+
if (fs.existsSync(path.join(p, "testkit.manifest.json"))) return p;
|
|
137
|
+
throw new Error(`No testkit.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, "testkit.manifest.json"))) return cwd;
|
|
125
142
|
|
|
126
143
|
throw new Error(
|
|
127
|
-
`
|
|
128
|
-
`
|
|
144
|
+
`No testkit.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, "testkit.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
|
*/
|
|
@@ -29,6 +62,7 @@ export async function neonUp(config) {
|
|
|
29
62
|
await runScript("neon-up.sh", {
|
|
30
63
|
NEON_PROJECT_ID: tk.neon.projectId,
|
|
31
64
|
NEON_DB_NAME: tk.neon.dbName,
|
|
65
|
+
NEON_BRANCH_NAME: tk.neon.branchName || `${config.name}-test`,
|
|
32
66
|
STATE_DIR: stateDir,
|
|
33
67
|
});
|
|
34
68
|
}
|
|
@@ -111,7 +145,6 @@ export async function runTests(config, files) {
|
|
|
111
145
|
await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
|
|
112
146
|
} catch (err) {
|
|
113
147
|
failed = true;
|
|
114
|
-
// Continue running remaining files — report all failures, don't stop at first
|
|
115
148
|
}
|
|
116
149
|
}
|
|
117
150
|
return { failed };
|
|
@@ -158,15 +191,18 @@ export function showStatus(config) {
|
|
|
158
191
|
}
|
|
159
192
|
for (const file of fs.readdirSync(stateDir)) {
|
|
160
193
|
if (file === "fly-env.sh") continue;
|
|
161
|
-
const
|
|
194
|
+
const filePath = path.join(stateDir, file);
|
|
195
|
+
if (fs.statSync(filePath).isDirectory()) continue;
|
|
196
|
+
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
162
197
|
console.log(` ${file}: ${val}`);
|
|
163
198
|
}
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
/**
|
|
167
|
-
*
|
|
202
|
+
* Run a single service: orchestrate HTTP and/or DAL test flows.
|
|
203
|
+
* Returns true if any tests failed.
|
|
168
204
|
*/
|
|
169
|
-
|
|
205
|
+
async function runService(config, suiteType, suiteNames, opts) {
|
|
170
206
|
const { manifest, stateDir } = config;
|
|
171
207
|
|
|
172
208
|
// Collect test files from manifest, partitioned by flow
|
|
@@ -189,7 +225,8 @@ export async function run(config, suiteType, suiteNames, opts) {
|
|
|
189
225
|
}
|
|
190
226
|
|
|
191
227
|
if (!httpFiles.length && !dalFiles.length) {
|
|
192
|
-
|
|
228
|
+
console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
|
|
229
|
+
return false;
|
|
193
230
|
}
|
|
194
231
|
|
|
195
232
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
@@ -199,7 +236,7 @@ export async function run(config, suiteType, suiteNames, opts) {
|
|
|
199
236
|
|
|
200
237
|
// HTTP flow: build → neon → fly → run → fly down
|
|
201
238
|
if (httpFiles.length) {
|
|
202
|
-
requireFlyToken(
|
|
239
|
+
requireFlyToken(config.name);
|
|
203
240
|
if (opts.build) await build(config);
|
|
204
241
|
await neonUp(config);
|
|
205
242
|
neonReady = true;
|
|
@@ -219,5 +256,5 @@ export async function run(config, suiteType, suiteNames, opts) {
|
|
|
219
256
|
if (result?.failed) failed = true;
|
|
220
257
|
}
|
|
221
258
|
|
|
222
|
-
|
|
259
|
+
return failed;
|
|
223
260
|
}
|