@elench/testkit 0.1.52 → 0.1.54
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 +14 -0
- package/bin/testkit.mjs +4 -6
- package/lib/cli/command-helpers.mjs +170 -0
- package/lib/cli/commands/artifacts.mjs +45 -0
- package/lib/cli/commands/cleanup.mjs +15 -0
- package/lib/cli/commands/db/snapshot/capture.mjs +22 -0
- package/lib/cli/commands/destroy.mjs +15 -0
- package/lib/cli/commands/known-failures/render.mjs +19 -0
- package/lib/cli/commands/known-failures/validate.mjs +20 -0
- package/lib/cli/commands/logs.mjs +47 -0
- package/lib/cli/commands/run.mjs +23 -0
- package/lib/cli/commands/show.mjs +47 -0
- package/lib/cli/commands/status.mjs +15 -0
- package/lib/cli/commands/watch.mjs +23 -0
- package/lib/cli/entrypoint.mjs +83 -0
- package/lib/cli/index.mjs +6 -116
- package/lib/cli/presentation/code-frames.mjs +57 -0
- package/lib/cli/presentation/code-frames.test.mjs +71 -0
- package/lib/cli/presentation/colors.mjs +29 -0
- package/lib/cli/presentation/run-reporter.mjs +100 -0
- package/lib/cli/tui/watch-app.mjs +104 -0
- package/lib/cli/viewer.mjs +268 -0
- package/lib/known-failures/index.mjs +1 -1
- package/lib/known-failures/index.test.mjs +46 -0
- package/lib/runner/artifacts.mjs +35 -0
- package/lib/runner/default-runtime-errors.mjs +66 -0
- package/lib/runner/default-runtime-runner.mjs +52 -11
- package/lib/runner/failure-details.mjs +31 -0
- package/lib/runner/failure-details.test.mjs +51 -0
- package/lib/runner/formatting.mjs +207 -0
- package/lib/runner/formatting.test.mjs +81 -6
- package/lib/runner/logs.mjs +89 -0
- package/lib/runner/orchestrator.mjs +51 -20
- package/lib/runner/playwright-runner.mjs +15 -7
- package/lib/runner/processes.mjs +9 -11
- package/lib/runner/reporting.mjs +5 -1
- package/lib/runner/reporting.test.mjs +4 -1
- package/lib/runner/runtime-contexts.mjs +7 -3
- package/lib/runner/runtime-manager.mjs +8 -2
- package/lib/runner/runtime-preparation.mjs +9 -4
- package/lib/runner/services.mjs +25 -8
- package/lib/runner/template-steps.mjs +4 -3
- package/lib/runner/triage.mjs +67 -0
- package/lib/runner/worker-loop.mjs +8 -7
- package/lib/runtime/index.d.ts +60 -0
- package/lib/runtime/index.mjs +12 -0
- package/lib/runtime-src/k6/checks.js +45 -12
- package/lib/runtime-src/k6/http-assertions.js +214 -0
- package/lib/runtime-src/k6/http.js +261 -13
- package/lib/runtime-src/k6/suite.js +46 -1
- package/lib/toolchains/index.mjs +6 -3
- package/package.json +13 -3
package/lib/cli/index.mjs
CHANGED
|
@@ -1,119 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
runKnownFailuresRenderCommand,
|
|
5
|
-
runKnownFailuresValidateCommand,
|
|
6
|
-
} from "./known-failures.mjs";
|
|
7
|
-
import { runDatabaseSnapshotCaptureCommand } from "./db.mjs";
|
|
8
|
-
import {
|
|
9
|
-
parseFileTimeoutOption,
|
|
10
|
-
parseShardOption,
|
|
11
|
-
parseSuiteOption,
|
|
12
|
-
parseTypeOption,
|
|
13
|
-
parseWorkersOption,
|
|
14
|
-
resolveRequestedFiles,
|
|
15
|
-
resolveCliSelection,
|
|
16
|
-
} from "./args.mjs";
|
|
17
|
-
import * as runner from "../runner/index.mjs";
|
|
1
|
+
import { execute } from "@oclif/core";
|
|
2
|
+
import { normalizeCliArgs } from "./entrypoint.mjs";
|
|
18
3
|
|
|
19
4
|
export async function run(argv = process.argv) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.option("--service <name>", "Run only one service")
|
|
25
|
-
.option("-t, --type <name>", "Run specific suite type(s): int, e2e, dal, load, pw, all", {
|
|
26
|
-
default: [],
|
|
27
|
-
})
|
|
28
|
-
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
29
|
-
.option("-f, --file <path>", "Run specific file(s)", { default: [] })
|
|
30
|
-
.option("--dir <path>", "Explicit product directory")
|
|
31
|
-
.option("--workers <n>", "Number of test executors for the whole run")
|
|
32
|
-
.option("--file-timeout-seconds <n>", "Per-file wall-clock timeout in seconds")
|
|
33
|
-
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
34
|
-
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
35
|
-
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
36
|
-
.option("--input <path>", "Known failures JSON path (repo-relative)")
|
|
37
|
-
.option("--output <path>", "Output path for render/snapshot commands")
|
|
38
|
-
.option("--status <path>", "Status artifact path for validation commands")
|
|
39
|
-
.option("--issue-mode <mode>", "Issue validation mode override: off, warn, error")
|
|
40
|
-
.option(
|
|
41
|
-
"--ignore-skip-rules",
|
|
42
|
-
"Run files even if testkit.setup.ts marks them skipped"
|
|
43
|
-
)
|
|
44
|
-
.action(async (first, second, third, options) => {
|
|
45
|
-
const { lifecycle, positionalType, knownFailuresAction, dbAction } = resolveCliSelection({
|
|
46
|
-
first,
|
|
47
|
-
second,
|
|
48
|
-
third,
|
|
49
|
-
});
|
|
50
|
-
if (knownFailuresAction === "render") {
|
|
51
|
-
await runKnownFailuresRenderCommand(options);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (knownFailuresAction === "validate") {
|
|
55
|
-
await runKnownFailuresValidateCommand(options);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (dbAction === "snapshot-capture") {
|
|
59
|
-
await runDatabaseSnapshotCaptureCommand(options);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const allConfigs = await loadConfigs({ dir: options.dir });
|
|
64
|
-
const configs = options.service
|
|
65
|
-
? allConfigs.filter((config) => config.name === options.service)
|
|
66
|
-
: allConfigs;
|
|
67
|
-
if (options.service && configs.length === 0) {
|
|
68
|
-
const available = allConfigs.map((config) => config.name).join(", ");
|
|
69
|
-
throw new Error(`Service "${options.service}" not found. Available: ${available}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Lifecycle commands
|
|
73
|
-
if (lifecycle === "cleanup") {
|
|
74
|
-
await runner.cleanup(allConfigs[0]?.productDir || process.cwd());
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (lifecycle === "status" || lifecycle === "destroy") {
|
|
79
|
-
for (const config of configs) {
|
|
80
|
-
if (configs.length > 1) console.log(`\n── ${config.name} ──`);
|
|
81
|
-
if (lifecycle === "status") runner.showStatus(config);
|
|
82
|
-
else await runner.destroy(config);
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const workers = options.workers == null ? null : parseWorkersOption(options.workers);
|
|
88
|
-
const fileTimeoutSeconds =
|
|
89
|
-
options.fileTimeoutSeconds == null
|
|
90
|
-
? null
|
|
91
|
-
: parseFileTimeoutOption(options.fileTimeoutSeconds);
|
|
92
|
-
const shard = parseShardOption(options.shard);
|
|
93
|
-
const typeValues = parseTypeOption(options.type, positionalType);
|
|
94
|
-
const suiteSelectors = parseSuiteOption(options.suite);
|
|
95
|
-
const rawFileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
|
|
96
|
-
const productDir = allConfigs[0]?.productDir || process.cwd();
|
|
97
|
-
const fileNames = resolveRequestedFiles(rawFileNames, productDir, process.cwd());
|
|
98
|
-
await runner.runAll(
|
|
99
|
-
configs,
|
|
100
|
-
typeValues,
|
|
101
|
-
suiteSelectors,
|
|
102
|
-
{
|
|
103
|
-
...options,
|
|
104
|
-
typeValues,
|
|
105
|
-
fileNames,
|
|
106
|
-
workers,
|
|
107
|
-
fileTimeoutSeconds,
|
|
108
|
-
shard,
|
|
109
|
-
serviceFilter: options.service || null,
|
|
110
|
-
},
|
|
111
|
-
allConfigs
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
cli.help();
|
|
116
|
-
const parsed = cli.parse(argv, { run: false });
|
|
117
|
-
await cli.runMatchedCommand();
|
|
118
|
-
return parsed;
|
|
5
|
+
return execute({
|
|
6
|
+
args: normalizeCliArgs(argv.slice(2)),
|
|
7
|
+
dir: import.meta.url,
|
|
8
|
+
});
|
|
119
9
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { codeFrameColumns } from "@babel/code-frame";
|
|
4
|
+
import { createColors } from "picocolors";
|
|
5
|
+
|
|
6
|
+
const pc = createColors(Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR));
|
|
7
|
+
|
|
8
|
+
export function findFailureLocation(detail = {}, fallbackError = "") {
|
|
9
|
+
if (detail?.location?.path && Number.isFinite(detail?.location?.line)) {
|
|
10
|
+
return detail.location;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const stack = detail?.stack || fallbackError;
|
|
14
|
+
if (!stack) return null;
|
|
15
|
+
const match = String(stack).match(/(\/[^\s:()]+):(\d+):(\d+)/);
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
return {
|
|
18
|
+
path: match[1],
|
|
19
|
+
line: Number(match[2]),
|
|
20
|
+
column: Number(match[3]),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderCodeFrame(location, options = {}) {
|
|
25
|
+
if (!location?.path || !Number.isFinite(location.line)) return [];
|
|
26
|
+
if (!fs.existsSync(location.path)) return [];
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const source = fs.readFileSync(location.path, "utf8");
|
|
30
|
+
const frame = codeFrameColumns(
|
|
31
|
+
source,
|
|
32
|
+
{
|
|
33
|
+
start: {
|
|
34
|
+
line: location.line,
|
|
35
|
+
column: Math.max(1, Number(location.column) || 1),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
highlightCode: Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR),
|
|
40
|
+
linesAbove: options.linesAbove ?? 2,
|
|
41
|
+
linesBelow: options.linesBelow ?? 2,
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
const label = `${options.label || "code"}: ${formatLocation(location, options.cwd)}`;
|
|
45
|
+
return [pc.bold(label), ...frame.split(/\r?\n/)];
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatLocation(location, cwd = process.cwd()) {
|
|
52
|
+
if (!location?.path || !Number.isFinite(location.line)) return null;
|
|
53
|
+
const relativePath = path.isAbsolute(location.path)
|
|
54
|
+
? path.relative(cwd, location.path) || path.basename(location.path)
|
|
55
|
+
: location.path;
|
|
56
|
+
return `${relativePath}:${location.line}:${location.column || 1}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { findFailureLocation, formatLocation, renderCodeFrame } from "./code-frames.mjs";
|
|
6
|
+
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
while (cleanups.length > 0) {
|
|
11
|
+
cleanups.pop()();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("code frame presentation", () => {
|
|
16
|
+
it("finds a location from structured failure metadata", () => {
|
|
17
|
+
expect(
|
|
18
|
+
findFailureLocation({
|
|
19
|
+
location: {
|
|
20
|
+
path: "/tmp/example.ts",
|
|
21
|
+
line: 10,
|
|
22
|
+
column: 2,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
).toEqual({
|
|
26
|
+
path: "/tmp/example.ts",
|
|
27
|
+
line: 10,
|
|
28
|
+
column: 2,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders a code frame for a local file", () => {
|
|
33
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-code-frame-"));
|
|
34
|
+
cleanups.push(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
|
35
|
+
const filePath = path.join(tempDir, "example.ts");
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
filePath,
|
|
38
|
+
[
|
|
39
|
+
"export function run() {",
|
|
40
|
+
" const value = 1;",
|
|
41
|
+
" return value + missing;",
|
|
42
|
+
"}",
|
|
43
|
+
].join("\n")
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const lines = renderCodeFrame(
|
|
47
|
+
{
|
|
48
|
+
path: filePath,
|
|
49
|
+
line: 3,
|
|
50
|
+
column: 18,
|
|
51
|
+
},
|
|
52
|
+
{ cwd: tempDir }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(lines.join("\n")).toContain("example.ts:3:18");
|
|
56
|
+
expect(lines.join("\n")).toContain("return value + missing;");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("formats locations relative to the cwd", () => {
|
|
60
|
+
expect(
|
|
61
|
+
formatLocation(
|
|
62
|
+
{
|
|
63
|
+
path: "/tmp/example.ts",
|
|
64
|
+
line: 7,
|
|
65
|
+
column: 3,
|
|
66
|
+
},
|
|
67
|
+
"/tmp"
|
|
68
|
+
)
|
|
69
|
+
).toBe("example.ts:7:3");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createColors } from "picocolors";
|
|
2
|
+
|
|
3
|
+
const pc = createColors(Boolean(process.stdout?.isTTY || process.env.FORCE_COLOR));
|
|
4
|
+
|
|
5
|
+
export function colorStatus(status) {
|
|
6
|
+
if (status === "PASS") return pc.green(status);
|
|
7
|
+
if (status === "FAIL") return pc.red(status);
|
|
8
|
+
if (status === "SKIP") return pc.yellow(status);
|
|
9
|
+
if (status === "RUN") return pc.cyan(status);
|
|
10
|
+
return status;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function dim(text) {
|
|
14
|
+
return pc.dim(text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function colorResultLine(line) {
|
|
18
|
+
if (/^Result: PASSED\b/.test(line)) return line.replace("PASSED", pc.green("PASSED"));
|
|
19
|
+
if (/^Result: FAILED\b/.test(line)) return line.replace("FAILED", pc.red("FAILED"));
|
|
20
|
+
return line;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function colorSectionLine(line) {
|
|
24
|
+
if (line === "Failures:" || line === "Runtime Errors:" || line === "Known-failure issues:" || line === "Triage:") {
|
|
25
|
+
return pc.bold(line);
|
|
26
|
+
}
|
|
27
|
+
if (/^Summary: /.test(line)) return line.replace("Summary:", `${pc.bold("Summary:")}`);
|
|
28
|
+
return colorResultLine(line);
|
|
29
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import {
|
|
3
|
+
buildCompactRunSummaryLines,
|
|
4
|
+
buildDebugRunSummaryLines,
|
|
5
|
+
formatDuration,
|
|
6
|
+
} from "../../runner/formatting.mjs";
|
|
7
|
+
import { colorSectionLine, colorStatus, dim } from "./colors.mjs";
|
|
8
|
+
|
|
9
|
+
export function createRunReporter({ outputMode = "compact", stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
10
|
+
const mode = outputMode || "compact";
|
|
11
|
+
return {
|
|
12
|
+
outputMode: mode,
|
|
13
|
+
writeLine(line = "") {
|
|
14
|
+
stdout.write(`${line}\n`);
|
|
15
|
+
},
|
|
16
|
+
writeDebugLine(line = "") {
|
|
17
|
+
stdout.write(`${line}\n`);
|
|
18
|
+
},
|
|
19
|
+
phaseStarted(label) {
|
|
20
|
+
if (mode !== "debug") return;
|
|
21
|
+
stdout.write(`\n── ${label} ──\n`);
|
|
22
|
+
},
|
|
23
|
+
toolchainResolved(config, resolvedToolchain) {
|
|
24
|
+
if (mode !== "debug") return;
|
|
25
|
+
stdout.write(
|
|
26
|
+
`[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}\n`
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
localServiceStarting(config, command) {
|
|
30
|
+
if (mode !== "debug") return;
|
|
31
|
+
stdout.write(`Starting ${config.runtimeLabel}:${config.name}: ${command}\n`);
|
|
32
|
+
},
|
|
33
|
+
serviceSkipped(config, reason) {
|
|
34
|
+
stdout.write(`${colorStatus("SKIP")} ${config.name} ${reason}\n`);
|
|
35
|
+
},
|
|
36
|
+
plannedSkip(entry) {
|
|
37
|
+
stdout.write(
|
|
38
|
+
`${colorStatus("SKIP")} ${entry.serviceName} ${entry.type} ${normalizePath(entry.file)} ${dim("0s")} ${shortenMessage(entry.reason || "skipped")}\n`
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
taskStarted(task, targetConfig) {
|
|
42
|
+
if (mode !== "debug") return;
|
|
43
|
+
stdout.write(`${colorStatus("RUN").padEnd(12)} ${targetConfig.name} ${task.type} ${task.file}\n`);
|
|
44
|
+
},
|
|
45
|
+
taskFinished(task, outcome) {
|
|
46
|
+
if (mode === "json") return;
|
|
47
|
+
const status = outcome.status === "skipped" ? "SKIP" : outcome.failed ? "FAIL" : "PASS";
|
|
48
|
+
const duration = formatDuration(outcome.durationMs || 0);
|
|
49
|
+
const primaryFailure = firstFailureDetail(outcome);
|
|
50
|
+
const preferredFailure =
|
|
51
|
+
primaryFailure && isThresholdWrapperMessage(outcome.error) ? primaryFailure : outcome.error || primaryFailure;
|
|
52
|
+
const detail =
|
|
53
|
+
status === "FAIL"
|
|
54
|
+
? ` ${shortenMessage(preferredFailure || "failed")}`
|
|
55
|
+
: outcome.status === "not_run"
|
|
56
|
+
? ` ${shortenMessage(outcome.reason || "not run")}`
|
|
57
|
+
: "";
|
|
58
|
+
stdout.write(
|
|
59
|
+
`${colorStatus(status)} ${task.serviceName} ${displayTaskType(task)} ${normalizePath(task.file)} ${dim(duration)}${detail}\n`
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
telemetry(message) {
|
|
63
|
+
if (mode === "json") return;
|
|
64
|
+
stdout.write(`${message}\n`);
|
|
65
|
+
},
|
|
66
|
+
runSummary(results, durationMs, knownFailureIssueValidation = null) {
|
|
67
|
+
const lines =
|
|
68
|
+
mode === "debug"
|
|
69
|
+
? buildDebugRunSummaryLines(results, durationMs, knownFailureIssueValidation)
|
|
70
|
+
: buildCompactRunSummaryLines(results, durationMs, knownFailureIssueValidation);
|
|
71
|
+
for (const line of lines) stdout.write(`${colorSectionLine(line)}\n`);
|
|
72
|
+
},
|
|
73
|
+
error(message) {
|
|
74
|
+
stderr.write(`${message}\n`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function displayTaskType(task) {
|
|
80
|
+
if (task.framework === "playwright") return "pw";
|
|
81
|
+
if (task.type === "integration") return "int";
|
|
82
|
+
return task.type;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shortenMessage(message) {
|
|
86
|
+
return String(message).replace(/\s+/g, " ").trim().slice(0, 180);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function firstFailureDetail(outcome) {
|
|
90
|
+
const detail = outcome.failureDetails?.[0];
|
|
91
|
+
return detail?.message || detail?.title || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isThresholdWrapperMessage(message) {
|
|
95
|
+
return /Default runtime thresholds failed:/.test(String(message || ""));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizePath(filePath) {
|
|
99
|
+
return String(filePath).split(path.sep).join("/");
|
|
100
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
3
|
+
import { formatDuration } from "../../runner/formatting.mjs";
|
|
4
|
+
import { formatFileDetail, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
5
|
+
|
|
6
|
+
export function WatchApp({ productDir, serviceFilter = null }) {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const [artifact, setArtifact] = useState(() => safeLoadArtifact(productDir));
|
|
9
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
10
|
+
|
|
11
|
+
const files = useMemo(() => collectFiles(artifact, serviceFilter), [artifact, serviceFilter]);
|
|
12
|
+
const selected = files[Math.min(selectedIndex, Math.max(0, files.length - 1))] || null;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const timer = setInterval(() => {
|
|
16
|
+
setArtifact(safeLoadArtifact(productDir));
|
|
17
|
+
}, 1_000);
|
|
18
|
+
return () => clearInterval(timer);
|
|
19
|
+
}, [productDir]);
|
|
20
|
+
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (input === "q") {
|
|
23
|
+
exit();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (input === "r") {
|
|
27
|
+
setArtifact(safeLoadArtifact(productDir));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (key.downArrow) {
|
|
31
|
+
setSelectedIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (key.upArrow) {
|
|
35
|
+
setSelectedIndex((current) => Math.max(0, current - 1));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!artifact) {
|
|
40
|
+
return createElement(Text, null, `No run artifact found in ${productDir}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return createElement(
|
|
44
|
+
Box,
|
|
45
|
+
{ flexDirection: "column" },
|
|
46
|
+
createElement(
|
|
47
|
+
Text,
|
|
48
|
+
null,
|
|
49
|
+
`testkit watch · q quit · r reload · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
50
|
+
),
|
|
51
|
+
createElement(
|
|
52
|
+
Box,
|
|
53
|
+
{ marginTop: 1 },
|
|
54
|
+
createElement(
|
|
55
|
+
Box,
|
|
56
|
+
{ width: "40%", flexDirection: "column", marginRight: 2 },
|
|
57
|
+
createElement(Text, null, "Files"),
|
|
58
|
+
...files.map((entry, index) => {
|
|
59
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
60
|
+
return createElement(
|
|
61
|
+
Text,
|
|
62
|
+
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
63
|
+
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
),
|
|
67
|
+
createElement(
|
|
68
|
+
Box,
|
|
69
|
+
{ width: "60%", flexDirection: "column" },
|
|
70
|
+
createElement(Text, null, "Details"),
|
|
71
|
+
...(selected
|
|
72
|
+
? formatFileDetail(productDir, artifact, selected, { logTail: 8 })
|
|
73
|
+
.slice(0, 28)
|
|
74
|
+
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
75
|
+
: [createElement(Text, { key: "empty" }, "No file results")])
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeLoadArtifact(productDir) {
|
|
82
|
+
try {
|
|
83
|
+
return loadLatestRunArtifact(productDir);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectFiles(runArtifact, serviceFilter) {
|
|
90
|
+
if (!runArtifact) return [];
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const service of runArtifact.services || []) {
|
|
93
|
+
if (serviceFilter && service.name !== serviceFilter) continue;
|
|
94
|
+
for (const suite of service.suites || []) {
|
|
95
|
+
for (const file of suite.files || []) {
|
|
96
|
+
entries.push({ service, suite, file });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const failed = entries.filter((entry) => entry.file.status === "failed");
|
|
101
|
+
return (failed.length > 0 ? failed : entries).sort((left, right) =>
|
|
102
|
+
left.file.path.localeCompare(right.file.path)
|
|
103
|
+
);
|
|
104
|
+
}
|