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