@elench/testkit 0.1.32 → 0.1.34

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.
@@ -0,0 +1,43 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import {
4
+ applyTimingUpdates,
5
+ createEmptyTimings,
6
+ normalizeTimings,
7
+ } from "../timing/index.mjs";
8
+
9
+ const TIMINGS_FILENAME = "timings.json";
10
+
11
+ export function writeRunArtifact(productDir, artifact) {
12
+ const resultsDir = path.join(productDir, ".testkit", "results");
13
+ fs.mkdirSync(resultsDir, { recursive: true });
14
+ fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
15
+ }
16
+
17
+ export function writeStatusArtifact(productDir, artifact) {
18
+ fs.writeFileSync(
19
+ path.join(productDir, "testkit.status.json"),
20
+ `${JSON.stringify(artifact, null, 2)}\n`
21
+ );
22
+ }
23
+
24
+ export function loadTimings(productDir) {
25
+ const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
26
+ if (!fs.existsSync(filePath)) {
27
+ return createEmptyTimings();
28
+ }
29
+
30
+ try {
31
+ return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
32
+ } catch {
33
+ return createEmptyTimings();
34
+ }
35
+ }
36
+
37
+ export function saveTimings(productDir, timings, updates) {
38
+ if (updates.length === 0) return;
39
+ const next = applyTimingUpdates(timings, updates);
40
+ const rootDir = path.join(productDir, ".testkit");
41
+ fs.mkdirSync(rootDir, { recursive: true });
42
+ fs.writeFileSync(path.join(rootDir, TIMINGS_FILENAME), JSON.stringify(next, null, 2));
43
+ }
@@ -0,0 +1,53 @@
1
+ export function determineDefaultRuntimeFailure(result, summary, firstLine) {
2
+ const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "", firstLine);
3
+ if (fatalRuntimeError) {
4
+ return `Default runtime uncaught error: ${fatalRuntimeError}`;
5
+ }
6
+
7
+ const failedThresholds = extractDefaultRuntimeThresholdFailures(summary);
8
+ if (failedThresholds.length > 0) {
9
+ return `Default runtime thresholds failed: ${failedThresholds.join(", ")}`;
10
+ }
11
+
12
+ if (result.exitCode !== 0) {
13
+ return sanitizeDefaultRuntimeExitError(
14
+ result.exitCode,
15
+ result.stderr || result.stdout || "",
16
+ firstLine
17
+ );
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ export function extractDefaultRuntimeFatalError(stderr, firstLine) {
24
+ if (!stderr || !/source=stacktrace/.test(stderr)) return null;
25
+ const matched = stderr.match(/Error:\s([^\n]+)/);
26
+ return matched?.[1]?.trim() || firstLine(stderr);
27
+ }
28
+
29
+ export function extractDefaultRuntimeThresholdFailures(summary) {
30
+ const metrics = summary?.metrics;
31
+ if (!metrics || typeof metrics !== "object") return [];
32
+
33
+ const failures = [];
34
+ for (const [metricName, metricSummary] of Object.entries(metrics)) {
35
+ const thresholds = metricSummary?.thresholds;
36
+ if (!thresholds || typeof thresholds !== "object") continue;
37
+ for (const [threshold, crossed] of Object.entries(thresholds)) {
38
+ if (crossed === true) {
39
+ failures.push(`${metricName}(${threshold})`);
40
+ }
41
+ }
42
+ }
43
+
44
+ return failures.sort();
45
+ }
46
+
47
+ export function sanitizeDefaultRuntimeExitError(exitCode, output, firstLine) {
48
+ const message = firstLine(output);
49
+ if (message) {
50
+ return `Default runtime failed with exit code ${exitCode}: ${message}`;
51
+ }
52
+ return `Default runtime failed with exit code ${exitCode}`;
53
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ determineDefaultRuntimeFailure,
4
+ extractDefaultRuntimeFatalError,
5
+ extractDefaultRuntimeThresholdFailures,
6
+ sanitizeDefaultRuntimeExitError,
7
+ } from "./default-runtime-errors.mjs";
8
+
9
+ const firstLine = (value) => value.split(/\r?\n/)[0];
10
+
11
+ describe("default runtime errors", () => {
12
+ it("extracts fatal stacktrace errors", () => {
13
+ expect(
14
+ extractDefaultRuntimeFatalError("source=stacktrace\nError: boom\nmore", firstLine)
15
+ ).toBe("boom");
16
+ });
17
+
18
+ it("extracts failed thresholds", () => {
19
+ expect(
20
+ extractDefaultRuntimeThresholdFailures({
21
+ metrics: {
22
+ checks: {
23
+ thresholds: {
24
+ "rate==1": true,
25
+ "rate>0.9": false,
26
+ },
27
+ },
28
+ other: {},
29
+ },
30
+ })
31
+ ).toEqual(["checks(rate==1)"]);
32
+ });
33
+
34
+ it("sanitizes exit errors", () => {
35
+ expect(sanitizeDefaultRuntimeExitError(99, "bad\nstack", firstLine)).toBe(
36
+ "Default runtime failed with exit code 99: bad"
37
+ );
38
+ });
39
+
40
+ it("chooses the highest-priority runtime failure", () => {
41
+ expect(
42
+ determineDefaultRuntimeFailure(
43
+ { exitCode: 0, stderr: "source=stacktrace\nError: boom", stdout: "" },
44
+ null,
45
+ firstLine
46
+ )
47
+ ).toBe("Default runtime uncaught error: boom");
48
+ });
49
+ });
@@ -0,0 +1,119 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execa } from "execa";
4
+ import { bundleK6File } from "../bundler/index.mjs";
5
+ import { resolveK6Binary } from "../config/index.mjs";
6
+ import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
7
+ import { formatBatchDescriptor } from "./formatting.mjs";
8
+ import { buildExecutionEnv } from "./template.mjs";
9
+ import { readDatabaseUrl } from "./state-io.mjs";
10
+
11
+ export async function runHttpK6Batch(targetConfig, batch, lifecycle) {
12
+ const baseUrl = targetConfig.testkit.local?.baseUrl;
13
+ if (!baseUrl) {
14
+ throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
15
+ }
16
+
17
+ console.log(
18
+ `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
19
+ );
20
+
21
+ return Promise.all(
22
+ batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
23
+ );
24
+ }
25
+
26
+ export async function runDalBatch(targetConfig, batch, lifecycle) {
27
+ const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
28
+ if (!databaseUrl) {
29
+ throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
30
+ }
31
+
32
+ console.log(
33
+ `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
34
+ );
35
+
36
+ return Promise.all(
37
+ batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
38
+ );
39
+ }
40
+
41
+ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
42
+ const bundledFile = await bundleK6File({
43
+ productDir: targetConfig.productDir,
44
+ serviceName: targetConfig.name,
45
+ sourceFile: path.join(targetConfig.productDir, task.file),
46
+ });
47
+ console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
48
+ return runDefaultRuntimeTask(
49
+ targetConfig,
50
+ task,
51
+ ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile],
52
+ lifecycle
53
+ );
54
+ }
55
+
56
+ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
57
+ const bundledFile = await bundleK6File({
58
+ productDir: targetConfig.productDir,
59
+ serviceName: targetConfig.name,
60
+ sourceFile: path.join(targetConfig.productDir, task.file),
61
+ });
62
+ console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
63
+ return runDefaultRuntimeTask(
64
+ targetConfig,
65
+ task,
66
+ ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, bundledFile],
67
+ lifecycle
68
+ );
69
+ }
70
+
71
+ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle, firstLine) {
72
+ const k6Binary = resolveK6Binary();
73
+ const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
74
+ fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
75
+ const startedAt = Date.now();
76
+ const result = await execa(
77
+ k6Binary,
78
+ [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)],
79
+ {
80
+ cwd: targetConfig.productDir,
81
+ env: buildExecutionEnv(targetConfig, {}, process.env),
82
+ reject: false,
83
+ cancelSignal: lifecycle.signal,
84
+ forceKillAfterDelay: 5_000,
85
+ }
86
+ );
87
+
88
+ if (result.stdout) process.stdout.write(result.stdout);
89
+ if (result.stderr) process.stderr.write(result.stderr);
90
+
91
+ const summary = readDefaultRuntimeSummary(summaryFile);
92
+ const runtimeError = determineDefaultRuntimeFailure(result, summary, firstLine);
93
+ const finishedAt = Date.now();
94
+
95
+ return {
96
+ task,
97
+ failed: runtimeError !== null,
98
+ error: runtimeError,
99
+ durationMs: finishedAt - startedAt,
100
+ startedAt,
101
+ finishedAt,
102
+ };
103
+ }
104
+
105
+ export function buildDefaultRuntimeSummaryPath(targetConfig, task) {
106
+ return path.join(
107
+ targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
108
+ "_runtime",
109
+ `task-${task.id}.summary.json`
110
+ );
111
+ }
112
+
113
+ export function readDefaultRuntimeSummary(filePath) {
114
+ try {
115
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
@@ -0,0 +1,129 @@
1
+ export function formatDuration(durationMs) {
2
+ const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
3
+ const minutes = Math.floor(totalSeconds / 60);
4
+ const seconds = totalSeconds % 60;
5
+ if (minutes === 0) return `${seconds}s`;
6
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
7
+ }
8
+
9
+ export function formatServiceSummary(result) {
10
+ const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
11
+ const notRunSuites = result.suiteCount - result.completedSuiteCount;
12
+ let detail = `${passedSuites}/${result.suiteCount} suites passed`;
13
+ if ((result.totalFileCount || 0) > 0) {
14
+ detail += `, ${result.passedFileCount}/${result.totalFileCount} files passed`;
15
+ }
16
+ if (notRunSuites > 0) {
17
+ detail += `, ${notRunSuites} ${pluralize(notRunSuites, "suite", "suites")} not run`;
18
+ } else if ((result.notRunFileCount || 0) > 0) {
19
+ detail += `, ${result.notRunFileCount} ${pluralize(result.notRunFileCount, "file", "files")} not run`;
20
+ }
21
+ return detail;
22
+ }
23
+
24
+ export function formatError(error) {
25
+ if (error instanceof Error) return sanitizeErrorMessage(error.message);
26
+ return sanitizeErrorMessage(String(error));
27
+ }
28
+
29
+ export function longestServiceName(results) {
30
+ return results.reduce((max, result) => Math.max(max, result.name.length), 4);
31
+ }
32
+
33
+ export function formatBatchDescriptor(batch) {
34
+ const fileLabel = `${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}`;
35
+ const frameworkLabel = formatFrameworkLabel(batch.framework);
36
+ return frameworkLabel ? ` (${frameworkLabel}, ${fileLabel})` : ` (${fileLabel})`;
37
+ }
38
+
39
+ export function formatPlaywrightBatchFiles(batch) {
40
+ if (!batch?.tasks?.length) return "";
41
+ const files = batch.tasks.map((task) => task.file);
42
+ if (files.length === 1) return ` · ${files[0]}`;
43
+ const preview = files.slice(0, 3).join(", ");
44
+ const suffix = files.length > 3 ? `, +${files.length - 3} more` : "";
45
+ return ` · ${preview}${suffix}`;
46
+ }
47
+
48
+ export function formatFrameworkLabel(framework) {
49
+ if (!framework || framework === "k6") return "";
50
+ return framework;
51
+ }
52
+
53
+ export function formatSuiteFramework(framework) {
54
+ const label = formatFrameworkLabel(framework);
55
+ return label ? ` [${label}]` : "";
56
+ }
57
+
58
+ export function buildRunSummaryLines(results, durationMs) {
59
+ const totalServices = results.length;
60
+ const executedServices = results.filter((result) => !result.skipped);
61
+ const skippedServices = results.filter((result) => result.skipped);
62
+ const failedServices = executedServices.filter((result) => result.failed);
63
+ const passedServices = executedServices.filter((result) => !result.failed);
64
+ const totalSuites = executedServices.reduce((sum, result) => sum + result.suiteCount, 0);
65
+ const completedSuites = executedServices.reduce(
66
+ (sum, result) => sum + result.completedSuiteCount,
67
+ 0
68
+ );
69
+ const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
70
+ const passedSuites = completedSuites - failedSuites;
71
+ const totalFiles = executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
72
+ const passedFiles = executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
73
+ const lines = [
74
+ "",
75
+ "══ Summary ══",
76
+ [
77
+ `services ${passedServices.length}/${executedServices.length} passed`,
78
+ `suites ${passedSuites}/${totalSuites} passed`,
79
+ totalFiles > 0 ? `files ${passedFiles}/${totalFiles} passed` : null,
80
+ skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
81
+ `duration ${formatDuration(durationMs)}`,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(" · "),
85
+ ];
86
+
87
+ for (const result of results) {
88
+ const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
89
+ const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
90
+ lines.push(
91
+ `${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
92
+ );
93
+
94
+ if (!result.failed) continue;
95
+
96
+ for (const suite of result.suites.filter((entry) => entry.failed)) {
97
+ const fileDetail =
98
+ suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
99
+ lines.push(
100
+ ` - ${suite.type}:${suite.name}${formatSuiteFramework(suite.framework)}${fileDetail} · ${formatDuration(suite.durationMs)}`
101
+ );
102
+ if (suite.error) {
103
+ lines.push(` ${suite.error}`);
104
+ }
105
+ }
106
+ for (const error of result.errors) {
107
+ lines.push(` - worker error: ${error}`);
108
+ }
109
+ }
110
+
111
+ if (failedServices.length > 0) {
112
+ lines.push("", `Result: FAILED (${failedServices.length}/${totalServices} services failed)`);
113
+ return lines;
114
+ }
115
+
116
+ lines.push("", "Result: PASSED");
117
+ return lines;
118
+ }
119
+
120
+ function sanitizeErrorMessage(message) {
121
+ return message
122
+ .replace(/Command failed with exit code (\d+): .*?[\\/]vendor[\\/]k6 run\b/g, "Default runtime failed with exit code $1:")
123
+ .replace(/Command failed with exit code (\d+): k6 run\b/g, "Default runtime failed with exit code $1:")
124
+ .replace(/[\\/]vendor[\\/]k6\b/g, "default-runtime");
125
+ }
126
+
127
+ function pluralize(value, singular, plural) {
128
+ return value === 1 ? singular : plural;
129
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildRunSummaryLines,
4
+ formatBatchDescriptor,
5
+ formatDuration,
6
+ formatError,
7
+ formatFrameworkLabel,
8
+ formatPlaywrightBatchFiles,
9
+ formatServiceSummary,
10
+ formatSuiteFramework,
11
+ longestServiceName,
12
+ } from "./formatting.mjs";
13
+
14
+ describe("runner formatting", () => {
15
+ it("formats durations compactly", () => {
16
+ expect(formatDuration(900)).toBe("1s");
17
+ expect(formatDuration(61_000)).toBe("1m 01s");
18
+ });
19
+
20
+ it("sanitizes default-runtime error messages", () => {
21
+ expect(
22
+ formatError(new Error("Command failed with exit code 99: /tmp/vendor/k6 run test.js"))
23
+ ).toBe("Default runtime failed with exit code 99: test.js");
24
+ });
25
+
26
+ it("formats service summaries with not-run files", () => {
27
+ expect(
28
+ formatServiceSummary({
29
+ completedSuiteCount: 2,
30
+ failedSuiteCount: 1,
31
+ suiteCount: 3,
32
+ totalFileCount: 5,
33
+ passedFileCount: 3,
34
+ notRunFileCount: 1,
35
+ })
36
+ ).toBe("1/3 suites passed, 3/5 files passed, 1 suite not run");
37
+ });
38
+
39
+ it("formats batch descriptors", () => {
40
+ expect(formatBatchDescriptor({ framework: "k6", tasks: [{}, {}] })).toBe(" (2 files)");
41
+ expect(formatBatchDescriptor({ framework: "playwright", tasks: [{}] })).toBe(
42
+ " (playwright, 1 file)"
43
+ );
44
+ });
45
+
46
+ it("formats Playwright file previews", () => {
47
+ expect(formatPlaywrightBatchFiles({ tasks: [{ file: "a" }] })).toBe(" · a");
48
+ expect(
49
+ formatPlaywrightBatchFiles({
50
+ tasks: [{ file: "a" }, { file: "b" }, { file: "c" }, { file: "d" }],
51
+ })
52
+ ).toBe(" · a, b, c, +1 more");
53
+ });
54
+
55
+ it("formats framework labels", () => {
56
+ expect(formatFrameworkLabel("k6")).toBe("");
57
+ expect(formatFrameworkLabel("playwright")).toBe("playwright");
58
+ expect(formatSuiteFramework("playwright")).toBe(" [playwright]");
59
+ });
60
+
61
+ it("computes longest service name", () => {
62
+ expect(longestServiceName([{ name: "api" }, { name: "frontend" }])).toBe(8);
63
+ });
64
+
65
+ it("builds printable summary lines", () => {
66
+ const lines = buildRunSummaryLines(
67
+ [
68
+ {
69
+ name: "frontend",
70
+ skipped: false,
71
+ failed: true,
72
+ suiteCount: 2,
73
+ completedSuiteCount: 2,
74
+ failedSuiteCount: 1,
75
+ totalFileCount: 3,
76
+ passedFileCount: 2,
77
+ durationMs: 20_000,
78
+ suites: [
79
+ {
80
+ failed: true,
81
+ type: "e2e",
82
+ name: "auth",
83
+ framework: "playwright",
84
+ failedFiles: ["a.pw.testkit.ts"],
85
+ durationMs: 12_000,
86
+ error: "boom",
87
+ },
88
+ ],
89
+ errors: ["worker broke"],
90
+ },
91
+ ],
92
+ 20_000
93
+ );
94
+
95
+ expect(lines.join("\n")).toContain("services 0/1 passed");
96
+ expect(lines.join("\n")).toContain("FAIL frontend");
97
+ expect(lines.join("\n")).toContain("worker error: worker broke");
98
+ expect(lines.at(-1)).toBe("Result: FAILED (1/1 services failed)");
99
+ });
100
+ });