@elench/testkit 0.1.17 → 0.1.19

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.
Files changed (53) hide show
  1. package/README.md +76 -16
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/bundler/index.mjs +95 -0
  4. package/lib/bundler/index.test.mjs +79 -0
  5. package/lib/cli/args.mjs +57 -0
  6. package/lib/cli/args.test.mjs +62 -0
  7. package/lib/cli/index.mjs +114 -0
  8. package/lib/config/index.mjs +294 -0
  9. package/lib/config/index.test.mjs +12 -0
  10. package/lib/config/model.mjs +422 -0
  11. package/lib/config/model.test.mjs +193 -0
  12. package/lib/database/fingerprint.mjs +61 -0
  13. package/lib/database/fingerprint.test.mjs +93 -0
  14. package/lib/{database.mjs → database/index.mjs} +45 -160
  15. package/lib/database/naming.mjs +47 -0
  16. package/lib/database/naming.test.mjs +39 -0
  17. package/lib/database/state.mjs +52 -0
  18. package/lib/database/state.test.mjs +66 -0
  19. package/lib/index.mjs +1 -0
  20. package/lib/k6/checks.mjs +1 -0
  21. package/lib/k6/dal-suite.mjs +1 -0
  22. package/lib/k6/dal.mjs +1 -0
  23. package/lib/k6/http.mjs +1 -0
  24. package/lib/k6/index.mjs +30 -0
  25. package/lib/k6/suite.mjs +1 -0
  26. package/lib/reporters/playwright.mjs +125 -0
  27. package/lib/reporters/playwright.test.mjs +73 -0
  28. package/lib/{runner.mjs → runner/index.mjs} +252 -835
  29. package/lib/runner/metadata.mjs +55 -0
  30. package/lib/runner/metadata.test.mjs +52 -0
  31. package/lib/runner/planning.mjs +270 -0
  32. package/lib/runner/planning.test.mjs +127 -0
  33. package/lib/runner/results.mjs +285 -0
  34. package/lib/runner/results.test.mjs +144 -0
  35. package/lib/runner/state.mjs +71 -0
  36. package/lib/runner/state.test.mjs +64 -0
  37. package/lib/runner/template.mjs +320 -0
  38. package/lib/runner/template.test.mjs +150 -0
  39. package/lib/runtime/index.mjs +191 -0
  40. package/lib/runtime-src/k6/checks.js +39 -0
  41. package/lib/runtime-src/k6/dal-suite.js +33 -0
  42. package/lib/runtime-src/k6/dal.js +32 -0
  43. package/lib/runtime-src/k6/http.js +134 -0
  44. package/lib/runtime-src/k6/suite.js +55 -0
  45. package/lib/telemetry/index.mjs +43 -0
  46. package/lib/timing/index.mjs +73 -0
  47. package/lib/timing/index.test.mjs +64 -0
  48. package/package.json +18 -3
  49. package/infra/neon-down.sh +0 -18
  50. package/infra/neon-up.sh +0 -124
  51. package/lib/cli.mjs +0 -132
  52. package/lib/config.mjs +0 -666
  53. package/lib/exec.mjs +0 -20
@@ -0,0 +1,33 @@
1
+ import { defaultOptions } from "./checks.js";
2
+ import { createDalContext, openDb } from "./dal.js";
3
+
4
+ export function defineDalSuite(configOrRun, maybeRun) {
5
+ const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
6
+ const db = config.db || openDb();
7
+ const dal = createDalContext(db);
8
+
9
+ return {
10
+ options: config.options || defaultOptions,
11
+ setup() {
12
+ if (typeof config.setup !== "function") return null;
13
+ return config.setup({ db, dal });
14
+ },
15
+ exec(setupData) {
16
+ return run({
17
+ db,
18
+ dal,
19
+ setupData,
20
+ });
21
+ },
22
+ };
23
+ }
24
+
25
+ function normalizeSuiteArgs(configOrRun, maybeRun) {
26
+ if (typeof configOrRun === "function") {
27
+ return { config: {}, run: configOrRun };
28
+ }
29
+ if (typeof maybeRun !== "function") {
30
+ throw new Error("suite factory requires a run callback");
31
+ }
32
+ return { config: configOrRun || {}, run: maybeRun };
33
+ }
@@ -0,0 +1,32 @@
1
+ import sql from "k6/x/sql";
2
+ import driver from "k6/x/sql/driver/postgres";
3
+ import {
4
+ allMatch,
5
+ contains,
6
+ defaultOptions,
7
+ isSorted,
8
+ } from "./checks.js";
9
+
10
+ export { allMatch, contains, defaultOptions, isSorted };
11
+
12
+ export function openDb() {
13
+ const url = __ENV.DATABASE_URL;
14
+ if (!url) {
15
+ throw new Error("DATABASE_URL env var required");
16
+ }
17
+ return sql.open(driver, url);
18
+ }
19
+
20
+ export function truncate(db, ...tables) {
21
+ if (tables.length === 0) return;
22
+ db.exec(`TRUNCATE ${tables.join(", ")} CASCADE`);
23
+ }
24
+
25
+ export function createDalContext(db = openDb()) {
26
+ return {
27
+ db,
28
+ truncate(...tables) {
29
+ return truncate(db, ...tables);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,134 @@
1
+ import http from "k6/http";
2
+ import { defaultOptions } from "./checks.js";
3
+
4
+ export { defaultOptions };
5
+
6
+ export function getEnv() {
7
+ const BASE = __ENV.BASE_URL;
8
+ const MACHINE_ID = __ENV.MACHINE_ID;
9
+
10
+ if (!BASE) {
11
+ throw new Error("BASE_URL env var required");
12
+ }
13
+
14
+ const routeParams = MACHINE_ID ? { "fly-force-instance-id": MACHINE_ID } : {};
15
+ return { BASE, MACHINE_ID, routeParams };
16
+ }
17
+
18
+ export function createHttpClient(config) {
19
+ const {
20
+ baseUrl,
21
+ routeHeaders = {},
22
+ defaultHeaders = { "Content-Type": "application/json" },
23
+ getHeaders = null,
24
+ getRawHeaders = null,
25
+ } = config;
26
+
27
+ if (!baseUrl) {
28
+ throw new Error("baseUrl is required");
29
+ }
30
+
31
+ function buildHeaders(builder, setupData, extraHeaders = {}) {
32
+ return {
33
+ ...defaultHeaders,
34
+ ...safeHeaders(builder, setupData),
35
+ ...routeHeaders,
36
+ ...extraHeaders,
37
+ };
38
+ }
39
+
40
+ function request(method, path, setupData, body, extraHeaders = {}) {
41
+ const url = `${baseUrl}${path}`;
42
+ const headers = buildHeaders(getHeaders, setupData, extraHeaders);
43
+ return runHttpRequest(method, url, body, headers);
44
+ }
45
+
46
+ function raw(method, path, body, extraHeaders = {}) {
47
+ const url = `${baseUrl}${path}`;
48
+ const headers = buildHeaders(getRawHeaders, null, extraHeaders);
49
+ return runHttpRequest(method, url, body, headers);
50
+ }
51
+
52
+ function getWithHeaders(path, setupData, extraHeaders = {}) {
53
+ return http.get(`${baseUrl}${path}`, {
54
+ headers: buildHeaders(getHeaders, setupData, extraHeaders),
55
+ });
56
+ }
57
+
58
+ return {
59
+ request,
60
+ raw,
61
+ get(path, setupData, extraHeaders = {}) {
62
+ return request("GET", path, setupData, null, extraHeaders);
63
+ },
64
+ put(path, setupData, body, extraHeaders = {}) {
65
+ return request("PUT", path, setupData, body, extraHeaders);
66
+ },
67
+ post(path, setupData, body, extraHeaders = {}) {
68
+ return request("POST", path, setupData, body, extraHeaders);
69
+ },
70
+ patch(path, setupData, body, extraHeaders = {}) {
71
+ return request("PATCH", path, setupData, body, extraHeaders);
72
+ },
73
+ delete(path, setupData, extraHeaders = {}) {
74
+ return request("DELETE", path, setupData, null, extraHeaders);
75
+ },
76
+ rawGet(path, extraHeaders = {}) {
77
+ return raw("GET", path, null, extraHeaders);
78
+ },
79
+ rawPost(path, body, extraHeaders = {}) {
80
+ return raw("POST", path, body, extraHeaders);
81
+ },
82
+ rawPut(path, body, extraHeaders = {}) {
83
+ return raw("PUT", path, body, extraHeaders);
84
+ },
85
+ rawPatch(path, body, extraHeaders = {}) {
86
+ return raw("PATCH", path, body, extraHeaders);
87
+ },
88
+ rawDelete(path, extraHeaders = {}) {
89
+ return raw("DELETE", path, null, extraHeaders);
90
+ },
91
+ getWithHeaders,
92
+ };
93
+ }
94
+
95
+ export function makeReq(baseUrl, routeHeaders = {}, getHeaders = null) {
96
+ return createHttpClient({
97
+ baseUrl,
98
+ routeHeaders,
99
+ getHeaders,
100
+ }).request;
101
+ }
102
+
103
+ export function makeRawReq(baseUrl, routeHeaders = {}, getRawHeaders = null) {
104
+ return createHttpClient({
105
+ baseUrl,
106
+ routeHeaders,
107
+ getRawHeaders,
108
+ }).raw;
109
+ }
110
+
111
+ export function makeGetWithHeaders(baseUrl, routeHeaders = {}, getHeaders = null) {
112
+ return createHttpClient({
113
+ baseUrl,
114
+ routeHeaders,
115
+ getHeaders,
116
+ }).getWithHeaders;
117
+ }
118
+
119
+ function runHttpRequest(method, url, body, headers) {
120
+ const options = { headers };
121
+
122
+ if (method === "GET") return http.get(url, options);
123
+ if (method === "PUT") return http.put(url, JSON.stringify(body), options);
124
+ if (method === "POST") return http.post(url, JSON.stringify(body), options);
125
+ if (method === "PATCH") return http.patch(url, JSON.stringify(body), options);
126
+ if (method === "DELETE") return http.del(url, null, options);
127
+
128
+ throw new Error(`unsupported method: ${method}`);
129
+ }
130
+
131
+ function safeHeaders(builder, setupData) {
132
+ if (typeof builder !== "function") return {};
133
+ return builder(setupData) || {};
134
+ }
@@ -0,0 +1,55 @@
1
+ import { defaultOptions } from "./checks.js";
2
+ import { createHttpClient, getEnv } from "./http.js";
3
+
4
+ export function defineHttpSuite(configOrRun, maybeRun) {
5
+ const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
6
+ const env = config.env || getEnv();
7
+ const auth = config.auth || null;
8
+
9
+ const client = createHttpClient({
10
+ baseUrl: env.BASE,
11
+ routeHeaders: env.routeParams,
12
+ getHeaders(setupData) {
13
+ return {
14
+ ...callHeaders(auth?.headers, setupData, env),
15
+ ...callHeaders(config.headers, setupData, env),
16
+ };
17
+ },
18
+ getRawHeaders(setupData) {
19
+ return callHeaders(config.rawHeaders, setupData, env);
20
+ },
21
+ });
22
+
23
+ return {
24
+ options: config.options || defaultOptions,
25
+ setup() {
26
+ if (typeof auth?.setup !== "function") return null;
27
+ return auth.setup({ env });
28
+ },
29
+ exec(setupData) {
30
+ return run({
31
+ env,
32
+ req: client.request,
33
+ rawReq: client.raw,
34
+ getWithHeaders: client.getWithHeaders,
35
+ setupData,
36
+ session: setupData,
37
+ });
38
+ },
39
+ };
40
+ }
41
+
42
+ function normalizeSuiteArgs(configOrRun, maybeRun) {
43
+ if (typeof configOrRun === "function") {
44
+ return { config: {}, run: configOrRun };
45
+ }
46
+ if (typeof maybeRun !== "function") {
47
+ throw new Error("suite factory requires a run callback");
48
+ }
49
+ return { config: configOrRun || {}, run: maybeRun };
50
+ }
51
+
52
+ function callHeaders(builder, setupData, env) {
53
+ if (typeof builder !== "function") return {};
54
+ return builder(setupData, { env }) || {};
55
+ }
@@ -0,0 +1,43 @@
1
+ export async function uploadTelemetryArtifact(telemetry, artifact) {
2
+ if (!telemetry?.enabled) return { skipped: true, reason: "disabled" };
3
+ if (process.env.TESTKIT_TELEMETRY === "0") {
4
+ return { skipped: true, reason: "disabled-by-env" };
5
+ }
6
+ if (!telemetry.endpoint) {
7
+ return { skipped: true, reason: "missing-endpoint" };
8
+ }
9
+ if (!telemetry.tokenEnv) {
10
+ return { skipped: true, reason: "missing-token-env" };
11
+ }
12
+
13
+ const token = process.env[telemetry.tokenEnv];
14
+ if (!token) {
15
+ return { skipped: true, reason: "missing-token" };
16
+ }
17
+
18
+ const controller = new AbortController();
19
+ const timeout = setTimeout(() => controller.abort(), telemetry.timeoutMs || 3_000);
20
+
21
+ try {
22
+ const response = await fetch(telemetry.endpoint, {
23
+ method: "POST",
24
+ headers: {
25
+ Authorization: `Bearer ${token}`,
26
+ "Content-Type": "application/json",
27
+ },
28
+ body: JSON.stringify(artifact),
29
+ signal: controller.signal,
30
+ });
31
+
32
+ if (!response.ok) {
33
+ const body = await response.text().catch(() => "");
34
+ throw new Error(
35
+ `telemetry upload failed with ${response.status}${body ? `: ${body.trim()}` : ""}`
36
+ );
37
+ }
38
+
39
+ return { skipped: false, ok: true };
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ }
43
+ }
@@ -0,0 +1,73 @@
1
+ import path from "path";
2
+
3
+ export function createEmptyTimings() {
4
+ return {
5
+ version: 1,
6
+ files: {},
7
+ };
8
+ }
9
+
10
+ export function normalizeTimings(parsed) {
11
+ return {
12
+ version: 1,
13
+ files: parsed?.files && typeof parsed.files === "object" ? parsed.files : {},
14
+ };
15
+ }
16
+
17
+ export function applyTimingUpdates(timings, updates, updatedAt = new Date().toISOString()) {
18
+ const next = {
19
+ version: 1,
20
+ files: { ...timings.files },
21
+ };
22
+
23
+ for (const update of updates) {
24
+ const existing = next.files[update.key];
25
+ if (!existing) {
26
+ next.files[update.key] = {
27
+ durationMs: Math.max(1, Math.round(update.durationMs)),
28
+ runs: 1,
29
+ updatedAt,
30
+ };
31
+ continue;
32
+ }
33
+
34
+ const runs = Number(existing.runs || 0) + 1;
35
+ const durationMs = Math.max(
36
+ 1,
37
+ Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
38
+ );
39
+ next.files[update.key] = {
40
+ durationMs,
41
+ runs,
42
+ updatedAt,
43
+ };
44
+ }
45
+
46
+ return next;
47
+ }
48
+
49
+ export function estimateTaskDuration(timings, timingKey, suite) {
50
+ const cached = timings.files[timingKey];
51
+ if (cached?.durationMs) return cached.durationMs;
52
+
53
+ const base =
54
+ suite.framework === "playwright"
55
+ ? 20_000
56
+ : suite.type === "dal"
57
+ ? 4_000
58
+ : 8_000;
59
+ return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
60
+ }
61
+
62
+ export function buildTimingKey(serviceName, suite, file) {
63
+ return [
64
+ serviceName,
65
+ suite.framework,
66
+ suite.type,
67
+ normalizePathSeparators(file),
68
+ ].join("|");
69
+ }
70
+
71
+ function normalizePathSeparators(filePath) {
72
+ return filePath.split(path.sep).join("/");
73
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyTimingUpdates,
4
+ buildTimingKey,
5
+ createEmptyTimings,
6
+ estimateTaskDuration,
7
+ normalizeTimings,
8
+ } from "./index.mjs";
9
+
10
+ describe("timings", () => {
11
+ it("creates and normalizes empty timing structures", () => {
12
+ expect(createEmptyTimings()).toEqual({ version: 1, files: {} });
13
+ expect(normalizeTimings({})).toEqual({ version: 1, files: {} });
14
+ });
15
+
16
+ it("applies rolling timing updates", () => {
17
+ const next = applyTimingUpdates(
18
+ {
19
+ version: 1,
20
+ files: {
21
+ "api|k6|integration|a.js": {
22
+ durationMs: 1000,
23
+ runs: 1,
24
+ updatedAt: "2020-01-01T00:00:00.000Z",
25
+ },
26
+ },
27
+ },
28
+ [{ key: "api|k6|integration|a.js", durationMs: 3000 }],
29
+ "2020-01-02T00:00:00.000Z"
30
+ );
31
+
32
+ expect(next.files["api|k6|integration|a.js"]).toEqual({
33
+ durationMs: 2000,
34
+ runs: 2,
35
+ updatedAt: "2020-01-02T00:00:00.000Z",
36
+ });
37
+ });
38
+
39
+ it("estimates task durations and builds timing keys", () => {
40
+ expect(
41
+ estimateTaskDuration(
42
+ { files: { key: { durationMs: 3210 } } },
43
+ "key",
44
+ { framework: "k6", type: "integration", weight: 1, files: ["a.js"] }
45
+ )
46
+ ).toBe(3210);
47
+
48
+ expect(
49
+ estimateTaskDuration(
50
+ { files: {} },
51
+ "key",
52
+ { framework: "playwright", type: "e2e", weight: 2, files: ["a.js", "b.js"] }
53
+ )
54
+ ).toBe(20000);
55
+
56
+ expect(
57
+ buildTimingKey(
58
+ "api",
59
+ { framework: "k6", type: "integration" },
60
+ "tests/health.js"
61
+ )
62
+ ).toBe("api|k6|integration|tests/health.js");
63
+ });
64
+ });
package/package.json CHANGED
@@ -1,20 +1,35 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "CLI for running manifest-defined local test suites across k6 and Playwright",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./lib/index.mjs",
8
+ "./k6": "./lib/k6/index.mjs",
9
+ "./k6/*": "./lib/k6/*.mjs",
10
+ "./package.json": "./package.json"
11
+ },
6
12
  "bin": {
7
13
  "testkit": "bin/testkit.mjs"
8
14
  },
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:unit": "vitest run lib",
18
+ "test:integration": "vitest run test/integration",
19
+ "test:system": "vitest run test/system --passWithNoTests"
20
+ },
9
21
  "files": [
10
22
  "bin/",
11
23
  "lib/",
12
- "infra/neon-up.sh",
13
- "infra/neon-down.sh",
14
24
  "vendor/"
15
25
  ],
26
+ "devDependencies": {
27
+ "@playwright/test": "^1.52.0",
28
+ "vitest": "^3.2.4"
29
+ },
16
30
  "dependencies": {
17
31
  "cac": "^6.7.14",
32
+ "esbuild": "^0.25.11",
18
33
  "execa": "^9.5.0"
19
34
  },
20
35
  "engines": {
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Deletes the ephemeral Neon branch from .state/
3
- # Requires: NEON_API_KEY, NEON_PROJECT_ID
4
- set -eo pipefail
5
-
6
- STATE_DIR="${STATE_DIR:-.state}"
7
- NEON_API="https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID"
8
-
9
- if [ ! -f "$STATE_DIR/neon_branch_id" ]; then
10
- echo "No Neon branch to clean up"
11
- exit 0
12
- fi
13
-
14
- BRANCH_ID=$(cat "$STATE_DIR/neon_branch_id")
15
- echo "Deleting Neon branch: $BRANCH_ID"
16
- curl -s -X DELETE "$NEON_API/branches/$BRANCH_ID" \
17
- -H "Authorization: Bearer $NEON_API_KEY" > /dev/null
18
- echo "Neon branch deleted"
package/infra/neon-up.sh DELETED
@@ -1,124 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Ensures a persistent Neon test branch exists and resets its data.
3
- # Creates the branch on first run; truncates tables on subsequent runs.
4
- # Requires: NEON_API_KEY, NEON_PROJECT_ID
5
- set -eo pipefail
6
-
7
- STATE_DIR="${STATE_DIR:-.state}"
8
- NEON_DB_NAME="${NEON_DB_NAME:-neondb}"
9
- BRANCH_NAME="${NEON_BRANCH_NAME:-testkit}"
10
- NEON_API="https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID"
11
-
12
- mkdir -p "$STATE_DIR"
13
-
14
- # ── 1. Check state file ──────────────────────────────────────────────────
15
- BRANCH_ID=""
16
- if [ -f "$STATE_DIR/neon_branch_id" ]; then
17
- STORED_ID=$(cat "$STATE_DIR/neon_branch_id")
18
- if curl -sf "$NEON_API/branches/$STORED_ID" \
19
- -H "Authorization: Bearer $NEON_API_KEY" >/dev/null 2>&1; then
20
- BRANCH_ID="$STORED_ID"
21
- echo "Neon branch exists: $BRANCH_ID"
22
- else
23
- echo "Stored branch $STORED_ID gone — will discover or create"
24
- rm -f "$STATE_DIR/neon_branch_id"
25
- fi
26
- fi
27
-
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 ───────────────────────────────────────────
42
- if [ -z "$BRANCH_ID" ]; then
43
- echo "Creating Neon branch: $BRANCH_NAME"
44
- RESPONSE=$(curl -sf -X POST "$NEON_API/branches" \
45
- -H "Authorization: Bearer $NEON_API_KEY" \
46
- -H "Content-Type: application/json" \
47
- -d "{
48
- \"branch\": { \"name\": \"$BRANCH_NAME\" },
49
- \"endpoints\": [{ \"type\": \"read_write\", \"suspend_timeout_seconds\": 300 }]
50
- }")
51
-
52
- BRANCH_ID=$(echo "$RESPONSE" | jq -r '.branch.id')
53
- if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then
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"
65
- fi
66
- echo "$BRANCH_ID" > "$STATE_DIR/neon_branch_id"
67
-
68
- ENDPOINT_ID=$(echo "$RESPONSE" | jq -r '.endpoints[0].id')
69
- if [ -n "$ENDPOINT_ID" ] && [ "$ENDPOINT_ID" != "null" ]; then
70
- echo "Waiting for endpoint $ENDPOINT_ID to become active..."
71
- for i in $(seq 1 30); do
72
- EP_STATE=$(curl -sf "$NEON_API/endpoints/$ENDPOINT_ID" \
73
- -H "Authorization: Bearer $NEON_API_KEY" \
74
- | jq -r '.endpoint.current_state')
75
- if [ "$EP_STATE" = "active" ] || [ "$EP_STATE" = "idle" ]; then
76
- echo "Endpoint ready (state: $EP_STATE)"
77
- break
78
- fi
79
- if [ "$i" -eq 30 ]; then
80
- echo "WARNING: Endpoint still '$EP_STATE' after 30s"
81
- fi
82
- sleep 1
83
- done
84
- fi
85
- fi
86
-
87
- # ── Get connection URI ───────────────────────────────────────────────────
88
- CONN_URI=$(curl -sf "$NEON_API/connection_uri?branch_id=$BRANCH_ID&database_name=$NEON_DB_NAME&role_name=neondb_owner" \
89
- -H "Authorization: Bearer $NEON_API_KEY" \
90
- | jq -r '.uri')
91
-
92
- if [ -z "$CONN_URI" ] || [ "$CONN_URI" = "null" ]; then
93
- echo "ERROR: Failed to get connection URI"
94
- exit 1
95
- fi
96
- echo "$CONN_URI" > "$STATE_DIR/database_url"
97
-
98
- # ── Reset test data ─────────────────────────────────────────────────────
99
- NEON_RESET="${NEON_RESET:-true}"
100
- if [ "$NEON_RESET" = "false" ]; then
101
- echo "Reset disabled — keeping fork data"
102
- elif command -v psql &>/dev/null; then
103
- echo "Resetting test data..."
104
- psql "$CONN_URI" -q -c "
105
- DO \$\$
106
- DECLARE r RECORD;
107
- BEGIN
108
- FOR r IN (
109
- SELECT tablename FROM pg_tables
110
- WHERE schemaname = 'public'
111
- AND tablename NOT LIKE '%migration%'
112
- AND tablename NOT LIKE 'goose_%'
113
- AND tablename NOT LIKE 'drizzle_%'
114
- ) LOOP
115
- EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
116
- END LOOP;
117
- END \$\$;
118
- " 2>/dev/null && echo "Tables truncated" || echo "First run — no tables to reset"
119
- else
120
- echo "WARNING: psql not available — skipping data reset"
121
- fi
122
-
123
- echo "Neon branch ready: $BRANCH_ID"
124
- echo "Database URL saved to $STATE_DIR/database_url"