@elench/testkit 0.1.16 → 0.1.18
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 +44 -19
- package/bin/testkit.mjs +1 -1
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +88 -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/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/runner/index.mjs +1221 -0
- 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/telemetry/index.mjs +43 -0
- package/lib/timing/index.mjs +73 -0
- package/lib/timing/index.test.mjs +64 -0
- package/package.json +11 -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
- package/lib/runner.mjs +0 -1165
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
const PORT_STRIDE = 100;
|
|
4
|
+
|
|
5
|
+
export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
|
|
6
|
+
const portMap = buildPortMap(runtimeConfigs, workerId);
|
|
7
|
+
const baseUrlByService = new Map();
|
|
8
|
+
const readyUrlByService = new Map();
|
|
9
|
+
|
|
10
|
+
for (const config of runtimeConfigs) {
|
|
11
|
+
if (!config.testkit.local) continue;
|
|
12
|
+
baseUrlByService.set(
|
|
13
|
+
config.name,
|
|
14
|
+
resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
|
|
15
|
+
workerStateDir,
|
|
16
|
+
portMap,
|
|
17
|
+
baseUrlByService,
|
|
18
|
+
readyUrlByService,
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
readyUrlByService.set(
|
|
22
|
+
config.name,
|
|
23
|
+
resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
|
|
24
|
+
workerStateDir,
|
|
25
|
+
portMap,
|
|
26
|
+
baseUrlByService,
|
|
27
|
+
readyUrlByService,
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const urlMappings = [];
|
|
33
|
+
for (const config of runtimeConfigs) {
|
|
34
|
+
if (!config.testkit.local) continue;
|
|
35
|
+
const resolvedBaseUrl = baseUrlByService.get(config.name);
|
|
36
|
+
const resolvedReadyUrl = readyUrlByService.get(config.name);
|
|
37
|
+
if (resolvedBaseUrl) {
|
|
38
|
+
urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
|
|
39
|
+
}
|
|
40
|
+
if (resolvedReadyUrl) {
|
|
41
|
+
urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return runtimeConfigs.map((config) =>
|
|
46
|
+
resolveWorkerConfig(
|
|
47
|
+
config,
|
|
48
|
+
targetConfig,
|
|
49
|
+
workerId,
|
|
50
|
+
workerStateDir,
|
|
51
|
+
portMap,
|
|
52
|
+
baseUrlByService,
|
|
53
|
+
readyUrlByService,
|
|
54
|
+
urlMappings
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildPortMap(runtimeConfigs, workerId) {
|
|
60
|
+
const portMap = new Map();
|
|
61
|
+
const seen = new Map();
|
|
62
|
+
const offset = PORT_STRIDE * (workerId - 1);
|
|
63
|
+
|
|
64
|
+
for (const config of runtimeConfigs) {
|
|
65
|
+
if (!config.testkit.local) continue;
|
|
66
|
+
|
|
67
|
+
const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
68
|
+
if (!basePort) continue;
|
|
69
|
+
|
|
70
|
+
const actualPort = basePort + offset;
|
|
71
|
+
const existing = seen.get(actualPort);
|
|
72
|
+
if (existing) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
|
|
75
|
+
`Assign distinct local.port/baseUrl ports in testkit.config.json.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
seen.set(actualPort, config.name);
|
|
79
|
+
portMap.set(config.name, actualPort);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return portMap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveWorkerConfig(
|
|
86
|
+
config,
|
|
87
|
+
targetConfig,
|
|
88
|
+
workerId,
|
|
89
|
+
workerStateDir,
|
|
90
|
+
portMap,
|
|
91
|
+
baseUrlByService,
|
|
92
|
+
readyUrlByService,
|
|
93
|
+
urlMappings
|
|
94
|
+
) {
|
|
95
|
+
const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
|
|
96
|
+
const context = {
|
|
97
|
+
workerId,
|
|
98
|
+
serviceName: config.name,
|
|
99
|
+
targetName: targetConfig.name,
|
|
100
|
+
serviceStateDir: stateDir,
|
|
101
|
+
portMap,
|
|
102
|
+
baseUrlByService,
|
|
103
|
+
readyUrlByService,
|
|
104
|
+
urlMappings,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const database = config.testkit.database
|
|
108
|
+
? {
|
|
109
|
+
...config.testkit.database,
|
|
110
|
+
}
|
|
111
|
+
: undefined;
|
|
112
|
+
|
|
113
|
+
const migrate = config.testkit.migrate
|
|
114
|
+
? {
|
|
115
|
+
...config.testkit.migrate,
|
|
116
|
+
cmd: finalizeString(config.testkit.migrate.cmd, context),
|
|
117
|
+
cwd:
|
|
118
|
+
config.testkit.migrate.cwd !== undefined
|
|
119
|
+
? finalizeString(config.testkit.migrate.cwd, context)
|
|
120
|
+
: config.testkit.migrate.cwd,
|
|
121
|
+
}
|
|
122
|
+
: undefined;
|
|
123
|
+
|
|
124
|
+
const seed = config.testkit.seed
|
|
125
|
+
? {
|
|
126
|
+
...config.testkit.seed,
|
|
127
|
+
cmd: finalizeString(config.testkit.seed.cmd, context),
|
|
128
|
+
cwd:
|
|
129
|
+
config.testkit.seed.cwd !== undefined
|
|
130
|
+
? finalizeString(config.testkit.seed.cwd, context)
|
|
131
|
+
: config.testkit.seed.cwd,
|
|
132
|
+
}
|
|
133
|
+
: undefined;
|
|
134
|
+
|
|
135
|
+
const local = config.testkit.local
|
|
136
|
+
? {
|
|
137
|
+
...config.testkit.local,
|
|
138
|
+
start: finalizeString(config.testkit.local.start, context),
|
|
139
|
+
cwd:
|
|
140
|
+
config.testkit.local.cwd !== undefined
|
|
141
|
+
? finalizeString(config.testkit.local.cwd, context)
|
|
142
|
+
: config.testkit.local.cwd,
|
|
143
|
+
port: portMap.get(config.name) || config.testkit.local.port,
|
|
144
|
+
baseUrl: baseUrlByService.get(config.name),
|
|
145
|
+
readyUrl: readyUrlByService.get(config.name),
|
|
146
|
+
env: Object.fromEntries(
|
|
147
|
+
Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
|
|
148
|
+
key,
|
|
149
|
+
finalizeString(String(value), context),
|
|
150
|
+
])
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...config,
|
|
157
|
+
stateDir,
|
|
158
|
+
workerId,
|
|
159
|
+
workerLabel: `w${workerId}`,
|
|
160
|
+
targetName: targetConfig.name,
|
|
161
|
+
testkit: {
|
|
162
|
+
...config.testkit,
|
|
163
|
+
database,
|
|
164
|
+
migrate,
|
|
165
|
+
seed,
|
|
166
|
+
local,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resolveServiceStateDir(workerStateDir, targetName, config) {
|
|
172
|
+
const dbSource = config.testkit.databaseFrom || config.name;
|
|
173
|
+
return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
177
|
+
if (targetName === serviceName) {
|
|
178
|
+
return workerStateDir;
|
|
179
|
+
}
|
|
180
|
+
return path.join(workerStateDir, "deps", serviceName);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
|
|
184
|
+
const inheritedEnv = { ...processEnv };
|
|
185
|
+
const env = {
|
|
186
|
+
...inheritedEnv,
|
|
187
|
+
...(config.testkit.serviceEnv || {}),
|
|
188
|
+
...extraEnv,
|
|
189
|
+
TESTKIT_ACTIVE: "1",
|
|
190
|
+
...(config.workerId ? { TESTKIT_WORKER_ID: String(config.workerId) } : {}),
|
|
191
|
+
};
|
|
192
|
+
delete env.DATABASE_URL;
|
|
193
|
+
return env;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function buildPlaywrightEnv(config, baseUrl, processEnv = process.env) {
|
|
197
|
+
return buildExecutionEnv(
|
|
198
|
+
config,
|
|
199
|
+
{
|
|
200
|
+
BASE_URL: baseUrl,
|
|
201
|
+
PLAYWRIGHT_HTML_OPEN: "never",
|
|
202
|
+
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
|
|
203
|
+
processEnv.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
|
|
204
|
+
TESTKIT_MANAGED_SERVERS: "1",
|
|
205
|
+
TESTKIT_WORKER_ID: String(config.workerId),
|
|
206
|
+
},
|
|
207
|
+
processEnv
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
212
|
+
const resolved = resolveTemplateString(rawUrl, {
|
|
213
|
+
...context,
|
|
214
|
+
targetName: targetConfig.name,
|
|
215
|
+
workerId,
|
|
216
|
+
serviceName,
|
|
217
|
+
});
|
|
218
|
+
const actualPort = context.portMap.get(serviceName);
|
|
219
|
+
return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function finalizeString(value, context) {
|
|
223
|
+
let resolved = resolveTemplateString(value, context);
|
|
224
|
+
for (const [source, destination] of context.urlMappings || []) {
|
|
225
|
+
if (source && destination && source !== destination) {
|
|
226
|
+
resolved = resolved.split(source).join(destination);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return resolved;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function resolveTemplateString(value, context) {
|
|
233
|
+
if (typeof value !== "string") return value;
|
|
234
|
+
|
|
235
|
+
return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
|
|
236
|
+
switch (token) {
|
|
237
|
+
case "worker":
|
|
238
|
+
return String(context.workerId);
|
|
239
|
+
case "target":
|
|
240
|
+
return context.targetName;
|
|
241
|
+
case "service":
|
|
242
|
+
return context.serviceName;
|
|
243
|
+
case "stateDir":
|
|
244
|
+
return context.serviceStateDir;
|
|
245
|
+
case "port": {
|
|
246
|
+
const serviceName = arg || context.serviceName;
|
|
247
|
+
const port = context.portMap.get(serviceName);
|
|
248
|
+
if (!port) {
|
|
249
|
+
throw new Error(`Unknown port placeholder for service "${serviceName}"`);
|
|
250
|
+
}
|
|
251
|
+
return String(port);
|
|
252
|
+
}
|
|
253
|
+
case "baseUrl": {
|
|
254
|
+
const serviceName = arg || context.serviceName;
|
|
255
|
+
const baseUrl = context.baseUrlByService.get(serviceName);
|
|
256
|
+
if (!baseUrl) {
|
|
257
|
+
throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
|
|
258
|
+
}
|
|
259
|
+
return baseUrl;
|
|
260
|
+
}
|
|
261
|
+
case "readyUrl": {
|
|
262
|
+
const serviceName = arg || context.serviceName;
|
|
263
|
+
const readyUrl = context.readyUrlByService.get(serviceName);
|
|
264
|
+
if (!readyUrl) {
|
|
265
|
+
throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
|
|
266
|
+
}
|
|
267
|
+
return readyUrl;
|
|
268
|
+
}
|
|
269
|
+
default:
|
|
270
|
+
throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function rewriteUrlPort(rawUrl, port) {
|
|
276
|
+
try {
|
|
277
|
+
const original = new URL(rawUrl);
|
|
278
|
+
if (!original.port) return rawUrl;
|
|
279
|
+
|
|
280
|
+
const rewritten = new URL(rawUrl);
|
|
281
|
+
rewritten.port = String(port);
|
|
282
|
+
|
|
283
|
+
let next = rewritten.toString();
|
|
284
|
+
if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
|
|
285
|
+
next = next.slice(0, -1);
|
|
286
|
+
}
|
|
287
|
+
return next;
|
|
288
|
+
} catch {
|
|
289
|
+
return rawUrl;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function numericPortFromUrl(rawUrl) {
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(rawUrl);
|
|
296
|
+
const port = Number(url.port);
|
|
297
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function socketFromUrl(rawUrl) {
|
|
304
|
+
try {
|
|
305
|
+
const url = new URL(rawUrl);
|
|
306
|
+
const port = Number(url.port);
|
|
307
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
308
|
+
|
|
309
|
+
const host = normalizeSocketHost(url.hostname);
|
|
310
|
+
return host ? { host, port } : null;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function normalizeSocketHost(hostname) {
|
|
317
|
+
if (!hostname || hostname === "localhost") return "127.0.0.1";
|
|
318
|
+
if (hostname === "[::1]") return "::1";
|
|
319
|
+
return hostname;
|
|
320
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildExecutionEnv,
|
|
4
|
+
buildPlaywrightEnv,
|
|
5
|
+
buildPortMap,
|
|
6
|
+
finalizeString,
|
|
7
|
+
getWorkerServiceStateDir,
|
|
8
|
+
normalizeSocketHost,
|
|
9
|
+
numericPortFromUrl,
|
|
10
|
+
resolveServiceStateDir,
|
|
11
|
+
resolveTemplateString,
|
|
12
|
+
resolveWorkerRuntimeConfigs,
|
|
13
|
+
rewriteUrlPort,
|
|
14
|
+
socketFromUrl,
|
|
15
|
+
} from "./template.mjs";
|
|
16
|
+
|
|
17
|
+
function makeRuntimeConfig(name, local, extras = {}) {
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
testkit: {
|
|
21
|
+
local,
|
|
22
|
+
serviceEnv: extras.serviceEnv || {},
|
|
23
|
+
databaseFrom: extras.databaseFrom,
|
|
24
|
+
migrate: extras.migrate,
|
|
25
|
+
seed: extras.seed,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("runner-template", () => {
|
|
31
|
+
it("builds port maps and detects collisions", () => {
|
|
32
|
+
const configs = [
|
|
33
|
+
makeRuntimeConfig("api", { port: 3000, baseUrl: "http://127.0.0.1:{port}" }),
|
|
34
|
+
makeRuntimeConfig("frontend", { port: 3001, baseUrl: "http://127.0.0.1:{port}" }),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
expect([...buildPortMap(configs, 2).entries()]).toEqual([
|
|
38
|
+
["api", 3100],
|
|
39
|
+
["frontend", 3101],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
expect(() =>
|
|
43
|
+
buildPortMap(
|
|
44
|
+
[
|
|
45
|
+
makeRuntimeConfig("api", { port: 3000, baseUrl: "http://127.0.0.1:{port}" }),
|
|
46
|
+
makeRuntimeConfig("other", { port: 3000, baseUrl: "http://127.0.0.1:{port}" }),
|
|
47
|
+
],
|
|
48
|
+
1
|
|
49
|
+
)
|
|
50
|
+
).toThrow("Worker port collision");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves template strings and URL rewrites", () => {
|
|
54
|
+
const context = {
|
|
55
|
+
workerId: 2,
|
|
56
|
+
targetName: "frontend",
|
|
57
|
+
serviceName: "frontend",
|
|
58
|
+
serviceStateDir: "/tmp/state",
|
|
59
|
+
portMap: new Map([
|
|
60
|
+
["frontend", 3200],
|
|
61
|
+
["api", 3100],
|
|
62
|
+
]),
|
|
63
|
+
baseUrlByService: new Map([["api", "http://127.0.0.1:3100"]]),
|
|
64
|
+
readyUrlByService: new Map([["api", "http://127.0.0.1:3100/health"]]),
|
|
65
|
+
urlMappings: [["http://api:3000", "http://127.0.0.1:3100"]],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
expect(resolveTemplateString("{worker}:{target}:{service}", context)).toBe("2:frontend:frontend");
|
|
69
|
+
expect(resolveTemplateString("{baseUrl:api}", context)).toBe("http://127.0.0.1:3100");
|
|
70
|
+
expect(finalizeString("API={baseUrl:api} OLD=http://api:3000", context)).toBe(
|
|
71
|
+
"API=http://127.0.0.1:3100 OLD=http://127.0.0.1:3100"
|
|
72
|
+
);
|
|
73
|
+
expect(rewriteUrlPort("http://127.0.0.1:3000/health", 3200)).toBe(
|
|
74
|
+
"http://127.0.0.1:3200/health"
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("builds worker runtime configs and execution env", () => {
|
|
79
|
+
const api = makeRuntimeConfig("api", {
|
|
80
|
+
cwd: ".",
|
|
81
|
+
start: "npm run api",
|
|
82
|
+
port: 3000,
|
|
83
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
84
|
+
readyUrl: "http://127.0.0.1:{port}/health",
|
|
85
|
+
env: {
|
|
86
|
+
PORT: "{port}",
|
|
87
|
+
},
|
|
88
|
+
}, {
|
|
89
|
+
serviceEnv: {
|
|
90
|
+
API_KEY: "secret",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const frontend = makeRuntimeConfig("frontend", {
|
|
94
|
+
cwd: "frontend",
|
|
95
|
+
start: "npm run web",
|
|
96
|
+
port: 3001,
|
|
97
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
98
|
+
readyUrl: "http://127.0.0.1:{port}",
|
|
99
|
+
env: {
|
|
100
|
+
NEXT_PUBLIC_API_URL: "{baseUrl:api}",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const resolved = resolveWorkerRuntimeConfigs(frontend, [api, frontend], 2, "/tmp/w2");
|
|
105
|
+
expect(resolved[0].testkit.local.port).toBe(3100);
|
|
106
|
+
expect(resolved[1].testkit.local.env.NEXT_PUBLIC_API_URL).toBe("http://127.0.0.1:3100");
|
|
107
|
+
expect(resolveServiceStateDir("/tmp/w2", "frontend", api)).toBe("/tmp/w2/deps/api");
|
|
108
|
+
expect(getWorkerServiceStateDir("/tmp/w2", "frontend", "frontend")).toBe("/tmp/w2");
|
|
109
|
+
|
|
110
|
+
expect(
|
|
111
|
+
buildExecutionEnv(
|
|
112
|
+
{
|
|
113
|
+
workerId: 2,
|
|
114
|
+
testkit: {
|
|
115
|
+
serviceEnv: {
|
|
116
|
+
API_KEY: "secret",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
DATABASE_URL: "gone",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
PATH: "/usr/bin",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
).toEqual({
|
|
128
|
+
PATH: "/usr/bin",
|
|
129
|
+
API_KEY: "secret",
|
|
130
|
+
TESTKIT_ACTIVE: "1",
|
|
131
|
+
TESTKIT_WORKER_ID: "2",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(buildPlaywrightEnv({ workerId: 3, testkit: { serviceEnv: {} } }, "http://localhost:3000", {}))
|
|
135
|
+
.toMatchObject({
|
|
136
|
+
BASE_URL: "http://localhost:3000",
|
|
137
|
+
TESTKIT_WORKER_ID: "3",
|
|
138
|
+
PLAYWRIGHT_HTML_OPEN: "never",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("parses runtime sockets", () => {
|
|
143
|
+
expect(numericPortFromUrl("http://localhost:3000")).toBe(3000);
|
|
144
|
+
expect(socketFromUrl("http://localhost:3000")).toEqual({
|
|
145
|
+
host: "127.0.0.1",
|
|
146
|
+
port: 3000,
|
|
147
|
+
});
|
|
148
|
+
expect(normalizeSocketHost("[::1]")).toBe("::1");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -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,18 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "CLI for running manifest-defined local test suites across k6 and Playwright",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"testkit": "bin/testkit.mjs"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:unit": "vitest run lib",
|
|
12
|
+
"test:integration": "vitest run test/integration",
|
|
13
|
+
"test:system": "vitest run test/system --passWithNoTests"
|
|
14
|
+
},
|
|
9
15
|
"files": [
|
|
10
16
|
"bin/",
|
|
11
17
|
"lib/",
|
|
12
|
-
"infra/neon-up.sh",
|
|
13
|
-
"infra/neon-down.sh",
|
|
14
18
|
"vendor/"
|
|
15
19
|
],
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@playwright/test": "^1.52.0",
|
|
22
|
+
"vitest": "^3.2.4"
|
|
23
|
+
},
|
|
16
24
|
"dependencies": {
|
|
17
25
|
"cac": "^6.7.14",
|
|
18
26
|
"execa": "^9.5.0"
|
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"
|