@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.
- package/README.md +76 -16
- package/bin/testkit.mjs +1 -1
- package/lib/bundler/index.mjs +95 -0
- package/lib/bundler/index.test.mjs +79 -0
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +114 -0
- package/lib/config/index.mjs +294 -0
- package/lib/config/index.test.mjs +12 -0
- package/lib/config/model.mjs +422 -0
- package/lib/config/model.test.mjs +193 -0
- package/lib/database/fingerprint.mjs +61 -0
- package/lib/database/fingerprint.test.mjs +93 -0
- package/lib/{database.mjs → database/index.mjs} +45 -160
- package/lib/database/naming.mjs +47 -0
- package/lib/database/naming.test.mjs +39 -0
- package/lib/database/state.mjs +52 -0
- package/lib/database/state.test.mjs +66 -0
- package/lib/index.mjs +1 -0
- package/lib/k6/checks.mjs +1 -0
- package/lib/k6/dal-suite.mjs +1 -0
- package/lib/k6/dal.mjs +1 -0
- package/lib/k6/http.mjs +1 -0
- package/lib/k6/index.mjs +30 -0
- package/lib/k6/suite.mjs +1 -0
- package/lib/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/{runner.mjs → runner/index.mjs} +252 -835
- package/lib/runner/metadata.mjs +55 -0
- package/lib/runner/metadata.test.mjs +52 -0
- package/lib/runner/planning.mjs +270 -0
- package/lib/runner/planning.test.mjs +127 -0
- package/lib/runner/results.mjs +285 -0
- package/lib/runner/results.test.mjs +144 -0
- package/lib/runner/state.mjs +71 -0
- package/lib/runner/state.test.mjs +64 -0
- package/lib/runner/template.mjs +320 -0
- package/lib/runner/template.test.mjs +150 -0
- package/lib/runtime/index.mjs +191 -0
- package/lib/runtime-src/k6/checks.js +39 -0
- package/lib/runtime-src/k6/dal-suite.js +33 -0
- package/lib/runtime-src/k6/dal.js +32 -0
- package/lib/runtime-src/k6/http.js +134 -0
- package/lib/runtime-src/k6/suite.js +55 -0
- package/lib/telemetry/index.mjs +43 -0
- package/lib/timing/index.mjs +73 -0
- package/lib/timing/index.test.mjs +64 -0
- package/package.json +18 -3
- package/infra/neon-down.sh +0 -18
- package/infra/neon-up.sh +0 -124
- package/lib/cli.mjs +0 -132
- package/lib/config.mjs +0 -666
- 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.
|
|
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": {
|
package/infra/neon-down.sh
DELETED
|
@@ -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"
|