@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,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,191 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const RUNNER_MANIFEST = "runner.manifest.json";
7
+ const TESTKIT_CONFIG = "testkit.config.json";
8
+ const DEFAULT_RUNTIME_DIR = path.join("tests", "_testkit");
9
+ const METADATA_FILE = ".runtime-manifest.json";
10
+ const RUNTIME_FORMAT = 1;
11
+
12
+ export function installRuntime(options = {}) {
13
+ const productDir = resolveProductDir(process.cwd(), options.dir);
14
+ const runtimeDir = resolveRuntimeDir(productDir, options.path);
15
+ const sourceFiles = readBundledRuntimeFiles();
16
+
17
+ fs.mkdirSync(runtimeDir, { recursive: true });
18
+
19
+ for (const file of sourceFiles) {
20
+ const targetPath = path.join(runtimeDir, file.path);
21
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
22
+ fs.writeFileSync(targetPath, file.content);
23
+ }
24
+
25
+ const metadata = {
26
+ format: RUNTIME_FORMAT,
27
+ package: "@elench/testkit",
28
+ version: readPackageVersion(),
29
+ files: sourceFiles.map((file) => ({
30
+ path: file.path,
31
+ sha256: hashContent(file.content),
32
+ })),
33
+ };
34
+ fs.writeFileSync(
35
+ path.join(runtimeDir, METADATA_FILE),
36
+ `${JSON.stringify(metadata, null, 2)}\n`
37
+ );
38
+
39
+ return {
40
+ productDir,
41
+ runtimeDir,
42
+ relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
43
+ files: metadata.files,
44
+ };
45
+ }
46
+
47
+ export function getRuntimeStatus(options = {}) {
48
+ const productDir = resolveProductDir(process.cwd(), options.dir);
49
+ const runtimeDir = resolveRuntimeDir(productDir, options.path);
50
+ const sourceFiles = readBundledRuntimeFiles();
51
+ const metadataPath = path.join(runtimeDir, METADATA_FILE);
52
+
53
+ if (!fs.existsSync(runtimeDir) || !fs.existsSync(metadataPath)) {
54
+ return {
55
+ status: "missing",
56
+ productDir,
57
+ runtimeDir,
58
+ relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
59
+ missingFiles: sourceFiles.map((file) => file.path),
60
+ driftedFiles: [],
61
+ };
62
+ }
63
+
64
+ const missingFiles = [];
65
+ const driftedFiles = [];
66
+
67
+ for (const file of sourceFiles) {
68
+ const targetPath = path.join(runtimeDir, file.path);
69
+ if (!fs.existsSync(targetPath)) {
70
+ missingFiles.push(file.path);
71
+ continue;
72
+ }
73
+
74
+ const installed = fs.readFileSync(targetPath, "utf8");
75
+ if (installed !== file.content) {
76
+ driftedFiles.push(file.path);
77
+ }
78
+ }
79
+
80
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
81
+ const versionMatches = metadata.version === readPackageVersion();
82
+
83
+ return {
84
+ status:
85
+ missingFiles.length === 0 && driftedFiles.length === 0 && versionMatches
86
+ ? "installed"
87
+ : "drifted",
88
+ productDir,
89
+ runtimeDir,
90
+ relativeRuntimeDir: relativeToProduct(productDir, runtimeDir),
91
+ versionMatches,
92
+ missingFiles,
93
+ driftedFiles,
94
+ };
95
+ }
96
+
97
+ export function formatRuntimeStatus(result) {
98
+ if (result.status === "missing") {
99
+ return `Runtime not installed at ${result.relativeRuntimeDir}`;
100
+ }
101
+
102
+ if (result.status === "installed") {
103
+ return `Runtime at ${result.relativeRuntimeDir} is up to date`;
104
+ }
105
+
106
+ const problems = [];
107
+ if (result.missingFiles.length > 0) {
108
+ problems.push(`missing: ${result.missingFiles.join(", ")}`);
109
+ }
110
+ if (result.driftedFiles.length > 0) {
111
+ problems.push(`drifted: ${result.driftedFiles.join(", ")}`);
112
+ }
113
+ if (result.versionMatches === false) {
114
+ problems.push("version drift");
115
+ }
116
+
117
+ return `Runtime at ${result.relativeRuntimeDir} is drifted (${problems.join("; ")})`;
118
+ }
119
+
120
+ function resolveProductDir(cwd, explicitDir) {
121
+ const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
122
+ ensureProductFiles(dir);
123
+ return dir;
124
+ }
125
+
126
+ function ensureProductFiles(dir) {
127
+ const missing = [RUNNER_MANIFEST, TESTKIT_CONFIG].filter(
128
+ (file) => !fs.existsSync(path.join(dir, file))
129
+ );
130
+
131
+ if (missing.length > 0) {
132
+ throw new Error(
133
+ `Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
134
+ );
135
+ }
136
+ }
137
+
138
+ function resolveRuntimeDir(productDir, explicitPath) {
139
+ return path.resolve(productDir, explicitPath || DEFAULT_RUNTIME_DIR);
140
+ }
141
+
142
+ function relativeToProduct(productDir, targetPath) {
143
+ return path.relative(productDir, targetPath) || ".";
144
+ }
145
+
146
+ function readBundledRuntimeFiles() {
147
+ const sourceDir = path.resolve(
148
+ path.dirname(fileURLToPath(import.meta.url)),
149
+ "..",
150
+ "runtime-src"
151
+ );
152
+
153
+ return walkRuntimeFiles(sourceDir);
154
+ }
155
+
156
+ function walkRuntimeFiles(rootDir, relativeDir = "") {
157
+ const entries = fs.readdirSync(path.join(rootDir, relativeDir), {
158
+ withFileTypes: true,
159
+ });
160
+ const files = [];
161
+
162
+ for (const entry of entries) {
163
+ const nextRelative = path.join(relativeDir, entry.name);
164
+ if (entry.isDirectory()) {
165
+ files.push(...walkRuntimeFiles(rootDir, nextRelative));
166
+ continue;
167
+ }
168
+
169
+ const absolute = path.join(rootDir, nextRelative);
170
+ files.push({
171
+ path: nextRelative.split(path.sep).join("/"),
172
+ content: fs.readFileSync(absolute, "utf8"),
173
+ });
174
+ }
175
+
176
+ return files.sort((left, right) => left.path.localeCompare(right.path));
177
+ }
178
+
179
+ function readPackageVersion() {
180
+ const packagePath = path.resolve(
181
+ path.dirname(fileURLToPath(import.meta.url)),
182
+ "..",
183
+ "..",
184
+ "package.json"
185
+ );
186
+ return JSON.parse(fs.readFileSync(packagePath, "utf8")).version;
187
+ }
188
+
189
+ function hashContent(content) {
190
+ return crypto.createHash("sha256").update(content).digest("hex");
191
+ }
@@ -0,0 +1,39 @@
1
+ export function singleIterationOptions(overrides = {}) {
2
+ return {
3
+ iterations: 1,
4
+ thresholds: {
5
+ checks: ["rate==1.0"],
6
+ ...(overrides.thresholds || {}),
7
+ },
8
+ ...overrides,
9
+ };
10
+ }
11
+
12
+ export const defaultOptions = singleIterationOptions();
13
+
14
+ export function json(res) {
15
+ return JSON.parse(res.body);
16
+ }
17
+
18
+ export function contains(rows, field, value) {
19
+ return rows.some((row) => row[field] === value);
20
+ }
21
+
22
+ export function allMatch(rows, predicate) {
23
+ return rows.length > 0 && rows.every(predicate);
24
+ }
25
+
26
+ export function isSorted(rows, field, direction = "asc") {
27
+ if (rows.length <= 1) return true;
28
+
29
+ for (let index = 1; index < rows.length; index += 1) {
30
+ if (direction === "asc" && rows[index][field] < rows[index - 1][field]) {
31
+ return false;
32
+ }
33
+ if (direction === "desc" && rows[index][field] > rows[index - 1][field]) {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ return true;
39
+ }