@elench/testkit 0.1.1 → 0.1.2
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/fly-build.sh +10 -0
- package/infra/fly-up.sh +43 -20
- package/infra/neon-up.sh +16 -3
- package/lib/cli.mjs +23 -3
- package/lib/config.mjs +185 -11
- package/lib/runner.mjs +67 -15
- package/package.json +2 -1
package/infra/fly-build.sh
CHANGED
|
@@ -29,6 +29,16 @@ if [ -f "$STATE_DIR/fly_image" ]; then
|
|
|
29
29
|
fi
|
|
30
30
|
fi
|
|
31
31
|
|
|
32
|
+
# Skip if image already exists in registry (e.g. built by a previous run without state)
|
|
33
|
+
if git -C "$API_DIR" diff --quiet 2>/dev/null; then
|
|
34
|
+
fly auth docker 2>/dev/null
|
|
35
|
+
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
|
|
36
|
+
echo "Image $IMAGE already in registry — skipping build"
|
|
37
|
+
echo "$IMAGE" > "$STATE_DIR/fly_image"
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
|
|
32
42
|
# Ensure the Fly app exists (registry requires it)
|
|
33
43
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
34
44
|
bash "$SCRIPT_DIR/fly-app-ensure.sh"
|
package/infra/fly-up.sh
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Ensures a Fly machine is running with the correct env vars and image.
|
|
3
3
|
# Reuses an existing stopped machine if available; creates a new one if not.
|
|
4
|
-
# Requires: FLY_APP, FLY_REGION
|
|
4
|
+
# Requires: FLY_APP, FLY_REGION
|
|
5
|
+
# Image required only for --build or new machines (FLY_IMAGE / FLY_IMAGE_DEFAULT / .state/fly_image)
|
|
5
6
|
# Optional: DB_URL_ENV_NAME (default: DATABASE_URL), FLY_PORT, FLY_ENV_FILE
|
|
6
7
|
set -eo pipefail
|
|
7
8
|
|
|
@@ -15,12 +16,11 @@ FLY_REGION="${FLY_REGION:-lhr}"
|
|
|
15
16
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
17
|
bash "$SCRIPT_DIR/fly-app-ensure.sh"
|
|
17
18
|
|
|
18
|
-
#
|
|
19
|
+
# Resolve image if available (not required yet — may not be needed for reuse)
|
|
19
20
|
if [ -z "$FLY_IMAGE" ] && [ -f "$STATE_DIR/fly_image" ]; then
|
|
20
21
|
FLY_IMAGE="$(cat "$STATE_DIR/fly_image")"
|
|
21
22
|
fi
|
|
22
23
|
FLY_IMAGE="${FLY_IMAGE:-$FLY_IMAGE_DEFAULT}"
|
|
23
|
-
FLY_IMAGE="${FLY_IMAGE:?FLY_IMAGE required — run fly-build.sh first or set FLY_IMAGE}"
|
|
24
24
|
|
|
25
25
|
if [ ! -f "$STATE_DIR/database_url" ]; then
|
|
26
26
|
echo "ERROR: No database_url — run neon-up.sh first"
|
|
@@ -40,33 +40,56 @@ fi
|
|
|
40
40
|
|
|
41
41
|
MACHINE_ID=""
|
|
42
42
|
|
|
43
|
-
# ── Try
|
|
43
|
+
# ── 1. Try state file ────────────────────────────────────────────────────
|
|
44
44
|
if [ -f "$STATE_DIR/fly_machine_id" ]; then
|
|
45
45
|
EXISTING_ID=$(cat "$STATE_DIR/fly_machine_id")
|
|
46
46
|
if fly machines status "$EXISTING_ID" --app "$FLY_APP" >/dev/null 2>&1; then
|
|
47
|
-
echo "Reusing machine $EXISTING_ID — updating config"
|
|
48
|
-
|
|
49
|
-
# Update env vars + image (handles DATABASE_URL change and code changes)
|
|
50
|
-
# shellcheck disable=SC2086
|
|
51
|
-
fly machines update "$EXISTING_ID" --app "$FLY_APP" \
|
|
52
|
-
--env "$DB_URL_ENV_NAME=$DATABASE_URL" \
|
|
53
|
-
$FLY_ENV_FLAGS \
|
|
54
|
-
--image "$FLY_IMAGE" \
|
|
55
|
-
--yes 2>&1
|
|
56
|
-
|
|
57
|
-
# Start if not already running (update may auto-restart)
|
|
58
|
-
fly machines start "$EXISTING_ID" --app "$FLY_APP" 2>/dev/null || true
|
|
59
|
-
fly machines wait "$EXISTING_ID" --app "$FLY_APP" --state started --wait-timeout 60s
|
|
60
|
-
|
|
61
47
|
MACHINE_ID="$EXISTING_ID"
|
|
62
48
|
else
|
|
63
|
-
echo "Stored machine $EXISTING_ID gone —
|
|
49
|
+
echo "Stored machine $EXISTING_ID gone — will discover or create"
|
|
64
50
|
rm -f "$STATE_DIR/fly_machine_id"
|
|
65
51
|
fi
|
|
66
52
|
fi
|
|
67
53
|
|
|
68
|
-
# ──
|
|
54
|
+
# ── 2. Discover existing machine on the app ──────────────────────────────
|
|
55
|
+
if [ -z "$MACHINE_ID" ]; then
|
|
56
|
+
DISCOVERED_ID=$(fly machines list --app "$FLY_APP" --json 2>/dev/null \
|
|
57
|
+
| jq -r '.[0].id // empty')
|
|
58
|
+
if [ -n "$DISCOVERED_ID" ]; then
|
|
59
|
+
MACHINE_ID="$DISCOVERED_ID"
|
|
60
|
+
echo "Discovered existing machine: $MACHINE_ID"
|
|
61
|
+
fi
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# ── 3. Update and start existing machine ─────────────────────────────────
|
|
65
|
+
if [ -n "$MACHINE_ID" ]; then
|
|
66
|
+
echo "Reusing machine $MACHINE_ID — updating config"
|
|
67
|
+
|
|
68
|
+
# Build update flags: always update DATABASE_URL + target env
|
|
69
|
+
UPDATE_FLAGS="--env $DB_URL_ENV_NAME=$DATABASE_URL $FLY_ENV_FLAGS"
|
|
70
|
+
|
|
71
|
+
# Only update image if one was provided (--build was used)
|
|
72
|
+
if [ -n "$FLY_IMAGE" ]; then
|
|
73
|
+
UPDATE_FLAGS="$UPDATE_FLAGS --image $FLY_IMAGE"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# shellcheck disable=SC2086
|
|
77
|
+
fly machines update "$MACHINE_ID" --app "$FLY_APP" \
|
|
78
|
+
$UPDATE_FLAGS \
|
|
79
|
+
--yes 2>&1
|
|
80
|
+
|
|
81
|
+
# Start if not already running (update may auto-restart)
|
|
82
|
+
fly machines start "$MACHINE_ID" --app "$FLY_APP" 2>/dev/null || true
|
|
83
|
+
fly machines wait "$MACHINE_ID" --app "$FLY_APP" --state started --wait-timeout 60s
|
|
84
|
+
|
|
85
|
+
echo "$MACHINE_ID" > "$STATE_DIR/fly_machine_id"
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# ── 4. Create new machine if needed ──────────────────────────────────────
|
|
69
89
|
if [ -z "$MACHINE_ID" ]; then
|
|
90
|
+
# Image is required for new machines
|
|
91
|
+
FLY_IMAGE="${FLY_IMAGE:?FLY_IMAGE required — run with --build or set FLY_IMAGE}"
|
|
92
|
+
|
|
70
93
|
echo "Creating Fly machine (app: $FLY_APP, region: $FLY_REGION)"
|
|
71
94
|
|
|
72
95
|
# shellcheck disable=SC2086
|
package/infra/neon-up.sh
CHANGED
|
@@ -11,7 +11,7 @@ NEON_API="https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID"
|
|
|
11
11
|
|
|
12
12
|
mkdir -p "$STATE_DIR"
|
|
13
13
|
|
|
14
|
-
# ── Check
|
|
14
|
+
# ── 1. Check state file ──────────────────────────────────────────────────
|
|
15
15
|
BRANCH_ID=""
|
|
16
16
|
if [ -f "$STATE_DIR/neon_branch_id" ]; then
|
|
17
17
|
STORED_ID=$(cat "$STATE_DIR/neon_branch_id")
|
|
@@ -20,12 +20,25 @@ if [ -f "$STATE_DIR/neon_branch_id" ]; then
|
|
|
20
20
|
BRANCH_ID="$STORED_ID"
|
|
21
21
|
echo "Neon branch exists: $BRANCH_ID"
|
|
22
22
|
else
|
|
23
|
-
echo "Stored branch $STORED_ID gone —
|
|
23
|
+
echo "Stored branch $STORED_ID gone — will discover or create"
|
|
24
24
|
rm -f "$STATE_DIR/neon_branch_id"
|
|
25
25
|
fi
|
|
26
26
|
fi
|
|
27
27
|
|
|
28
|
-
# ──
|
|
28
|
+
# ── 2. Discover existing branch by name ──────────────────────────────────
|
|
29
|
+
if [ -z "$BRANCH_ID" ]; then
|
|
30
|
+
EXISTING_ID=$(curl -sf "$NEON_API/branches" \
|
|
31
|
+
-H "Authorization: Bearer $NEON_API_KEY" \
|
|
32
|
+
| jq -r --arg name "$BRANCH_NAME" '.branches[] | select(.name == $name) | .id' | head -1)
|
|
33
|
+
|
|
34
|
+
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
|
|
35
|
+
BRANCH_ID="$EXISTING_ID"
|
|
36
|
+
echo "$BRANCH_ID" > "$STATE_DIR/neon_branch_id"
|
|
37
|
+
echo "Discovered existing Neon branch '$BRANCH_NAME': $BRANCH_ID"
|
|
38
|
+
fi
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# ── 3. Create branch if needed ───────────────────────────────────────────
|
|
29
42
|
if [ -z "$BRANCH_ID" ]; then
|
|
30
43
|
echo "Creating Neon branch: $BRANCH_NAME"
|
|
31
44
|
RESPONSE=$(curl -sf -X POST "$NEON_API/branches" \
|
package/lib/cli.mjs
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import { cac } from "cac";
|
|
2
|
-
import { loadConfig } from "./config.mjs";
|
|
2
|
+
import { loadConfig, inferProduct } from "./config.mjs";
|
|
3
3
|
import * as runner from "./runner.mjs";
|
|
4
4
|
|
|
5
|
+
const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
|
|
6
|
+
const LIFECYCLE = new Set(["status", "destroy"]);
|
|
7
|
+
|
|
5
8
|
export function run() {
|
|
6
9
|
const cli = cac("testkit");
|
|
7
10
|
|
|
8
11
|
cli
|
|
9
|
-
.command("
|
|
12
|
+
.command("[first] [second]", "Run test suites (int, e2e, dal, all)")
|
|
10
13
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
11
14
|
.option("--build", "Build image from source first", { default: false })
|
|
12
15
|
.option("--dir <path>", "Explicit product directory")
|
|
13
|
-
.action(async (
|
|
16
|
+
.action(async (first, second, options) => {
|
|
17
|
+
// Resolve product and type from positional args.
|
|
18
|
+
// Supports: testkit outreach int -s health
|
|
19
|
+
// testkit int -s health (infer product from cwd)
|
|
20
|
+
// testkit outreach (type defaults to "all")
|
|
21
|
+
// testkit (infer product, type "all")
|
|
22
|
+
let product, type;
|
|
23
|
+
|
|
24
|
+
if (first && !SUITE_TYPES.has(first) && !LIFECYCLE.has(first)) {
|
|
25
|
+
// First arg is a product name
|
|
26
|
+
product = first;
|
|
27
|
+
type = second;
|
|
28
|
+
} else {
|
|
29
|
+
// First arg is a type/lifecycle or missing — infer product from cwd
|
|
30
|
+
product = inferProduct();
|
|
31
|
+
type = first; // second is ignored (no product was given)
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
// Handle lifecycle subcommands
|
|
15
35
|
if (type === "status") {
|
|
16
36
|
const config = loadConfig(product, options);
|
package/lib/config.mjs
CHANGED
|
@@ -24,6 +24,26 @@ export function parseDotenv(filePath) {
|
|
|
24
24
|
return env;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Infer the product name from ./runner.manifest.json in the cwd.
|
|
29
|
+
* Used when the CLI is invoked without an explicit product argument.
|
|
30
|
+
*/
|
|
31
|
+
export function inferProduct(cwd) {
|
|
32
|
+
const dir = cwd || process.cwd();
|
|
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
|
+
}
|
|
40
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
41
|
+
if (typeof m.product !== "string" || !m.product) {
|
|
42
|
+
throw new Error(`runner.manifest.json is missing a "product" field`);
|
|
43
|
+
}
|
|
44
|
+
return m.product;
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* Find the product directory and load the manifest + env.
|
|
29
49
|
* Returns { productDir, manifest, stateDir }.
|
|
@@ -34,26 +54,22 @@ export function loadConfig(product, opts = {}) {
|
|
|
34
54
|
const manifestPath = path.join(productDir, "runner.manifest.json");
|
|
35
55
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
throw new Error(`No "testkit" section in ${manifestPath}`);
|
|
39
|
-
}
|
|
57
|
+
validateManifest(manifest, manifestPath);
|
|
40
58
|
|
|
41
59
|
// Load product .env into process.env (product wins on conflict)
|
|
42
60
|
const dotenv = parseDotenv(path.join(productDir, ".env"));
|
|
43
61
|
Object.assign(process.env, dotenv);
|
|
44
62
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
51
|
-
}
|
|
63
|
+
// Only require NEON_API_KEY unconditionally — Fly token is checked on demand
|
|
64
|
+
if (!process.env.NEON_API_KEY) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`NEON_API_KEY not found. Set it in your shell environment, .envrc, or ${product}/.env`
|
|
67
|
+
);
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
// Validate manifest secrets are available
|
|
55
71
|
const tk = manifest.testkit;
|
|
56
|
-
for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || [])]) {
|
|
72
|
+
for (const key of [...(tk.fly?.secrets || []), ...(tk.k6?.secrets || []), ...(tk.dal?.secrets || [])]) {
|
|
57
73
|
if (!process.env[key]) {
|
|
58
74
|
throw new Error(`Secret "${key}" (from manifest) not found in env or ${product}/.env`);
|
|
59
75
|
}
|
|
@@ -62,6 +78,33 @@ export function loadConfig(product, opts = {}) {
|
|
|
62
78
|
return { productDir, manifest, stateDir: path.join(productDir, ".testkit") };
|
|
63
79
|
}
|
|
64
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Require FLY_API_TOKEN. Call this only when HTTP tests need Fly infrastructure.
|
|
83
|
+
*/
|
|
84
|
+
export function requireFlyToken(product) {
|
|
85
|
+
if (!process.env.FLY_API_TOKEN) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`FLY_API_TOKEN not found. Set it in your shell environment, .envrc, or ${product}/.env`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the custom k6 binary for DAL tests. Returns the absolute path.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveDalBinary(config) {
|
|
96
|
+
const { productDir, manifest } = config;
|
|
97
|
+
const rel = manifest.testkit.dal?.k6Binary;
|
|
98
|
+
if (!rel) {
|
|
99
|
+
throw new Error("testkit.dal.k6Binary is required for DAL tests");
|
|
100
|
+
}
|
|
101
|
+
const abs = path.resolve(productDir, rel);
|
|
102
|
+
if (!fs.existsSync(abs)) {
|
|
103
|
+
throw new Error(`DAL k6 binary not found: ${abs}`);
|
|
104
|
+
}
|
|
105
|
+
return abs;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
function resolveProductDir(product, cwd, explicitDir) {
|
|
66
109
|
if (explicitDir) {
|
|
67
110
|
const p = path.resolve(cwd, explicitDir);
|
|
@@ -85,3 +128,134 @@ function resolveProductDir(product, cwd, explicitDir) {
|
|
|
85
128
|
`Run from the workspace root or use --dir.`
|
|
86
129
|
);
|
|
87
130
|
}
|
|
131
|
+
|
|
132
|
+
// --- Schema validation ---
|
|
133
|
+
|
|
134
|
+
function validateManifest(manifest, manifestPath) {
|
|
135
|
+
const errors = [];
|
|
136
|
+
const ctx = `in ${manifestPath}`;
|
|
137
|
+
|
|
138
|
+
requireString(errors, manifest, "product", ctx);
|
|
139
|
+
|
|
140
|
+
if (!isObject(manifest.testkit)) {
|
|
141
|
+
errors.push(`Manifest error: testkit is required and must be an object (${ctx})`);
|
|
142
|
+
} else {
|
|
143
|
+
const tk = manifest.testkit;
|
|
144
|
+
|
|
145
|
+
// testkit.neon
|
|
146
|
+
if (!isObject(tk.neon)) {
|
|
147
|
+
errors.push(`Manifest error: testkit.neon is required and must be an object (${ctx})`);
|
|
148
|
+
} else {
|
|
149
|
+
requireString(errors, tk.neon, "testkit.neon.projectId", ctx, "projectId");
|
|
150
|
+
requireString(errors, tk.neon, "testkit.neon.dbName", ctx, "dbName");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// testkit.fly
|
|
154
|
+
if (!isObject(tk.fly)) {
|
|
155
|
+
errors.push(`Manifest error: testkit.fly is required and must be an object (${ctx})`);
|
|
156
|
+
} else {
|
|
157
|
+
requireString(errors, tk.fly, "testkit.fly.app", ctx, "app");
|
|
158
|
+
requireString(errors, tk.fly, "testkit.fly.org", ctx, "org");
|
|
159
|
+
optionalString(errors, tk.fly, "testkit.fly.region", ctx, "region");
|
|
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");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// testkit.k6
|
|
166
|
+
if (tk.k6 !== undefined) {
|
|
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
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// testkit.dal
|
|
175
|
+
if (tk.dal !== undefined) {
|
|
176
|
+
if (!isObject(tk.dal)) {
|
|
177
|
+
errors.push(`Manifest error: testkit.dal must be an object (${ctx})`);
|
|
178
|
+
} else {
|
|
179
|
+
// k6Binary is required if suites.dal exists
|
|
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");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// suites
|
|
189
|
+
if (!isObject(manifest.suites)) {
|
|
190
|
+
errors.push(`Manifest error: suites is required and must be an object (${ctx})`);
|
|
191
|
+
} else {
|
|
192
|
+
const keys = Object.keys(manifest.suites);
|
|
193
|
+
if (keys.length === 0) {
|
|
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];
|
|
198
|
+
if (!Array.isArray(arr)) {
|
|
199
|
+
errors.push(`Manifest error: suites.${type} must be an array (${ctx})`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (let i = 0; i < arr.length; i++) {
|
|
203
|
+
const suite = arr[i];
|
|
204
|
+
const prefix = `suites.${type}[${i}]`;
|
|
205
|
+
requireString(errors, suite, `${prefix}.name`, ctx, "name");
|
|
206
|
+
requireNonEmptyStringArray(errors, suite, `${prefix}.files`, ctx, "files");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// dal.k6Binary required if suites.dal exists but testkit.dal is missing
|
|
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
|
+
}
|
|
220
|
+
|
|
221
|
+
function isObject(v) {
|
|
222
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function requireString(errors, obj, path, ctx, key) {
|
|
226
|
+
const k = key || path;
|
|
227
|
+
const val = obj[k];
|
|
228
|
+
if (typeof val !== "string" || val.length === 0) {
|
|
229
|
+
errors.push(`Manifest error: ${path} is required (${ctx})`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function optionalString(errors, obj, path, ctx, key) {
|
|
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) {
|
|
241
|
+
const val = obj[key];
|
|
242
|
+
if (val === undefined) return;
|
|
243
|
+
if (!Array.isArray(val) || !val.every(v => typeof v === "string")) {
|
|
244
|
+
errors.push(`Manifest error: ${path} must be a string[] (${ctx})`);
|
|
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})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function requireNonEmptyStringArray(errors, obj, path, ctx, key) {
|
|
257
|
+
const val = obj[key];
|
|
258
|
+
if (!Array.isArray(val) || val.length === 0 || !val.every(v => typeof v === "string")) {
|
|
259
|
+
errors.push(`Manifest error: ${path} is required and must be a non-empty string[] (${ctx})`);
|
|
260
|
+
}
|
|
261
|
+
}
|
package/lib/runner.mjs
CHANGED
|
@@ -2,6 +2,9 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { runScript } from "./exec.mjs";
|
|
4
4
|
import { execaCommand } from "execa";
|
|
5
|
+
import { requireFlyToken, resolveDalBinary } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const HTTP_SUITE_TYPES = new Set(["integration", "e2e", "load"]);
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Build the Docker image and push to Fly registry.
|
|
@@ -84,7 +87,7 @@ export async function destroy(config) {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
|
-
* Run k6 test files
|
|
90
|
+
* Run k6 test files against the Fly machine (HTTP tests).
|
|
88
91
|
*/
|
|
89
92
|
export async function runTests(config, files) {
|
|
90
93
|
const { productDir, stateDir, manifest } = config;
|
|
@@ -114,6 +117,36 @@ export async function runTests(config, files) {
|
|
|
114
117
|
return { failed };
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Run k6 DAL test files directly against Neon (no Fly machine).
|
|
122
|
+
*/
|
|
123
|
+
export async function runDalTests(config, files) {
|
|
124
|
+
const { productDir, stateDir, manifest } = config;
|
|
125
|
+
const tk = manifest.testkit;
|
|
126
|
+
const k6Binary = resolveDalBinary(config);
|
|
127
|
+
|
|
128
|
+
// Read DATABASE_URL from neon state
|
|
129
|
+
const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
|
|
130
|
+
|
|
131
|
+
// Build -e flags
|
|
132
|
+
const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
|
|
133
|
+
for (const key of tk.dal?.secrets || []) {
|
|
134
|
+
envFlags.push(`-e ${key}=${process.env[key]}`);
|
|
135
|
+
}
|
|
136
|
+
const envStr = envFlags.join(" ");
|
|
137
|
+
|
|
138
|
+
let failed = false;
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
const absFile = path.join(productDir, file);
|
|
141
|
+
try {
|
|
142
|
+
await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
failed = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { failed };
|
|
148
|
+
}
|
|
149
|
+
|
|
117
150
|
/**
|
|
118
151
|
* Show state directory contents.
|
|
119
152
|
*/
|
|
@@ -131,41 +164,60 @@ export function showStatus(config) {
|
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
/**
|
|
134
|
-
* Main entry:
|
|
167
|
+
* Main entry: orchestrate HTTP and/or DAL test flows.
|
|
135
168
|
*/
|
|
136
169
|
export async function run(config, suiteType, suiteNames, opts) {
|
|
137
170
|
const { manifest, stateDir } = config;
|
|
138
171
|
|
|
139
|
-
// Collect test files from manifest
|
|
172
|
+
// Collect test files from manifest, partitioned by flow
|
|
140
173
|
const types = suiteType === "all"
|
|
141
174
|
? Object.keys(manifest.suites)
|
|
142
175
|
: [suiteType === "int" ? "integration" : suiteType];
|
|
143
176
|
|
|
144
|
-
let
|
|
177
|
+
let httpFiles = [];
|
|
178
|
+
let dalFiles = [];
|
|
145
179
|
for (const type of types) {
|
|
146
180
|
const suites = manifest.suites[type] || [];
|
|
147
181
|
for (const suite of suites) {
|
|
148
182
|
if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
|
|
149
|
-
|
|
183
|
+
if (type === "dal") {
|
|
184
|
+
dalFiles.push(...suite.files);
|
|
185
|
+
} else {
|
|
186
|
+
httpFiles.push(...suite.files);
|
|
187
|
+
}
|
|
150
188
|
}
|
|
151
189
|
}
|
|
152
190
|
|
|
153
|
-
if (!
|
|
191
|
+
if (!httpFiles.length && !dalFiles.length) {
|
|
154
192
|
throw new Error(`No test files found for type="${suiteType}" suites=${suiteNames.join(",") || "all"}`);
|
|
155
193
|
}
|
|
156
194
|
|
|
157
195
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
158
196
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
197
|
+
let failed = false;
|
|
198
|
+
let neonReady = false;
|
|
199
|
+
|
|
200
|
+
// HTTP flow: build → neon → fly → run → fly down
|
|
201
|
+
if (httpFiles.length) {
|
|
202
|
+
requireFlyToken(manifest.product);
|
|
203
|
+
if (opts.build) await build(config);
|
|
204
|
+
await neonUp(config);
|
|
205
|
+
neonReady = true;
|
|
206
|
+
await flyUp(config);
|
|
207
|
+
try {
|
|
208
|
+
const result = await runTests(config, httpFiles);
|
|
209
|
+
if (result?.failed) failed = true;
|
|
210
|
+
} finally {
|
|
211
|
+
await flyDown(config);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
162
214
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
215
|
+
// DAL flow: neon (idempotent) → run DAL tests
|
|
216
|
+
if (dalFiles.length) {
|
|
217
|
+
if (!neonReady) await neonUp(config);
|
|
218
|
+
const result = await runDalTests(config, dalFiles);
|
|
219
|
+
if (result?.failed) failed = true;
|
|
168
220
|
}
|
|
169
221
|
|
|
170
|
-
if (
|
|
222
|
+
if (failed) process.exit(1);
|
|
171
223
|
}
|