@elench/testkit 0.1.53 → 0.1.55
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/lib/cli/commands/artifacts.mjs +2 -2
- package/lib/cli/commands/logs.mjs +2 -2
- package/lib/cli/commands/show.mjs +2 -2
- package/lib/cli/db.mjs +17 -2
- 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 +41 -7
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +146 -4
- package/lib/database/index.mjs +85 -11
- package/lib/database/template-steps.mjs +45 -6
- package/lib/database/template-steps.test.mjs +43 -0
- package/lib/known-failures/index.mjs +1 -1
- package/lib/known-failures/index.test.mjs +46 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/default-runtime-errors.mjs +66 -0
- package/lib/runner/default-runtime-runner.mjs +8 -1
- package/lib/runner/failure-details.mjs +31 -0
- package/lib/runner/failure-details.test.mjs +51 -0
- package/lib/runner/formatting.mjs +114 -4
- package/lib/runner/formatting.test.mjs +77 -0
- package/lib/runner/logs.mjs +71 -6
- package/lib/runner/orchestrator.mjs +63 -7
- package/lib/runner/reporting.mjs +52 -2
- package/lib/runner/reporting.test.mjs +80 -2
- package/lib/runner/runtime-contexts.mjs +3 -3
- package/lib/runner/runtime-preparation.mjs +31 -0
- package/lib/runner/setup-operations.mjs +115 -0
- package/lib/runner/setup-operations.test.mjs +94 -0
- package/lib/runner/template-steps.mjs +129 -11
- package/lib/runner/triage.mjs +67 -0
- 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 +0 -4
- package/package.json +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { collectArtifactEntries,
|
|
3
|
+
import { collectArtifactEntries, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ArtifactsCommand extends Command {
|
|
6
6
|
static summary = "List persisted artifacts from the latest run";
|
|
@@ -19,7 +19,7 @@ export default class ArtifactsCommand extends Command {
|
|
|
19
19
|
async run() {
|
|
20
20
|
const { args, flags } = await this.parse(ArtifactsCommand);
|
|
21
21
|
const productDir = flags.dir || process.cwd();
|
|
22
|
-
const runArtifact =
|
|
22
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
23
23
|
const entries = collectArtifactEntries(productDir, runArtifact, args.file || null, flags.service || null)
|
|
24
24
|
.map((entry) => ({
|
|
25
25
|
service: entry.service.name,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
3
|
import { readLogTail } from "../../runner/logs.mjs";
|
|
4
|
-
import { getServiceLogRefs,
|
|
4
|
+
import { getServiceLogRefs, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
5
5
|
import path from "path";
|
|
6
6
|
|
|
7
7
|
export default class LogsCommand extends Command {
|
|
@@ -27,7 +27,7 @@ export default class LogsCommand extends Command {
|
|
|
27
27
|
async run() {
|
|
28
28
|
const { args, flags } = await this.parse(LogsCommand);
|
|
29
29
|
const productDir = flags.dir || process.cwd();
|
|
30
|
-
const runArtifact =
|
|
30
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
31
31
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
32
32
|
const logs = getServiceLogRefs(runArtifact, subject.service.name).map((entry) => ({
|
|
33
33
|
...entry,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args, Command, Flags } from "@oclif/core";
|
|
2
2
|
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
-
import { formatFileDetail,
|
|
3
|
+
import { formatFileDetail, loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
4
4
|
|
|
5
5
|
export default class ShowCommand extends Command {
|
|
6
6
|
static summary = "Show the most useful details for one file from the latest run";
|
|
@@ -25,7 +25,7 @@ export default class ShowCommand extends Command {
|
|
|
25
25
|
async run() {
|
|
26
26
|
const { args, flags } = await this.parse(ShowCommand);
|
|
27
27
|
const productDir = flags.dir || process.cwd();
|
|
28
|
-
const runArtifact =
|
|
28
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
29
29
|
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
30
30
|
const result = {
|
|
31
31
|
file: subject.file,
|
package/lib/cli/db.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { loadConfigs, resolveProductDir } from "../config/index.mjs";
|
|
5
5
|
import { captureDatabaseTemplateSnapshot, prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
|
+
import { createRunReporter } from "./presentation/run-reporter.mjs";
|
|
7
|
+
import { createRunLogRegistry } from "../runner/logs.mjs";
|
|
8
|
+
import { createSetupOperationRegistry } from "../runner/setup-operations.mjs";
|
|
6
9
|
import { resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
|
|
7
10
|
|
|
8
11
|
export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
@@ -28,16 +31,28 @@ export async function runDatabaseSnapshotCaptureCommand(options = {}) {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const absoluteOutputPath = path.resolve(productDir, outputPath);
|
|
34
|
+
const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
|
|
35
|
+
const logRegistry = createRunLogRegistry(productDir);
|
|
36
|
+
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
31
37
|
try {
|
|
32
38
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
33
39
|
if (config.name === resolvedTarget.name) continue;
|
|
34
40
|
if (config.testkit.database?.provider === "local") {
|
|
35
|
-
await prepareDatabaseRuntime(config
|
|
41
|
+
await prepareDatabaseRuntime(config, {
|
|
42
|
+
reporter,
|
|
43
|
+
logRegistry,
|
|
44
|
+
setupRegistry,
|
|
45
|
+
});
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
|
-
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath
|
|
48
|
+
await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath, {
|
|
49
|
+
reporter,
|
|
50
|
+
logRegistry,
|
|
51
|
+
setupRegistry,
|
|
52
|
+
});
|
|
39
53
|
console.log(`Wrote ${path.relative(productDir, absoluteOutputPath)}`);
|
|
40
54
|
} finally {
|
|
55
|
+
logRegistry.closeAll();
|
|
41
56
|
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
42
57
|
}
|
|
43
58
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
buildDebugRunSummaryLines,
|
|
5
5
|
formatDuration,
|
|
6
6
|
} from "../../runner/formatting.mjs";
|
|
7
|
+
import { colorSectionLine, colorStatus, dim } from "./colors.mjs";
|
|
7
8
|
|
|
8
9
|
export function createRunReporter({ outputMode = "compact", stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
9
10
|
const mode = outputMode || "compact";
|
|
@@ -25,34 +26,58 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
25
26
|
`[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}\n`
|
|
26
27
|
);
|
|
27
28
|
},
|
|
29
|
+
setupOperationFinished(operation) {
|
|
30
|
+
if (!operation) return;
|
|
31
|
+
if (mode === "json") return;
|
|
32
|
+
if (operation.status === "cached") return;
|
|
33
|
+
const duration = formatDuration(operation.durationMs || 0);
|
|
34
|
+
if (operation.status === "failed") {
|
|
35
|
+
const detail = shortenMessage(operation.error || operation.summary || operation.stage);
|
|
36
|
+
stdout.write(
|
|
37
|
+
`${colorStatus("FAIL")} ${"SETUP"} ${operation.serviceName} ${operation.stage} ${dim(duration)} ${detail}\n`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
mode === "compact" &&
|
|
43
|
+
isHighLevelSetupOperation(operation) &&
|
|
44
|
+
(operation.durationMs || 0) >= 5_000
|
|
45
|
+
) {
|
|
46
|
+
const summary = shortenMessage(operation.summary || operation.stage);
|
|
47
|
+
stdout.write(`${colorStatus("RUN")} ${"SETUP"} ${operation.serviceName} ${summary} ${dim(duration)}\n`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
28
50
|
localServiceStarting(config, command) {
|
|
29
51
|
if (mode !== "debug") return;
|
|
30
52
|
stdout.write(`Starting ${config.runtimeLabel}:${config.name}: ${command}\n`);
|
|
31
53
|
},
|
|
32
54
|
serviceSkipped(config, reason) {
|
|
33
|
-
stdout.write(
|
|
55
|
+
stdout.write(`${colorStatus("SKIP")} ${config.name} ${reason}\n`);
|
|
34
56
|
},
|
|
35
57
|
plannedSkip(entry) {
|
|
36
58
|
stdout.write(
|
|
37
|
-
|
|
59
|
+
`${colorStatus("SKIP")} ${entry.serviceName} ${entry.type} ${normalizePath(entry.file)} ${dim("0s")} ${shortenMessage(entry.reason || "skipped")}\n`
|
|
38
60
|
);
|
|
39
61
|
},
|
|
40
62
|
taskStarted(task, targetConfig) {
|
|
41
63
|
if (mode !== "debug") return;
|
|
42
|
-
stdout.write(
|
|
64
|
+
stdout.write(`${colorStatus("RUN").padEnd(12)} ${targetConfig.name} ${task.type} ${task.file}\n`);
|
|
43
65
|
},
|
|
44
66
|
taskFinished(task, outcome) {
|
|
45
67
|
if (mode === "json") return;
|
|
46
68
|
const status = outcome.status === "skipped" ? "SKIP" : outcome.failed ? "FAIL" : "PASS";
|
|
47
69
|
const duration = formatDuration(outcome.durationMs || 0);
|
|
70
|
+
const primaryFailure = firstFailureDetail(outcome);
|
|
71
|
+
const preferredFailure =
|
|
72
|
+
primaryFailure && isThresholdWrapperMessage(outcome.error) ? primaryFailure : outcome.error || primaryFailure;
|
|
48
73
|
const detail =
|
|
49
74
|
status === "FAIL"
|
|
50
|
-
? ` ${shortenMessage(
|
|
75
|
+
? ` ${shortenMessage(preferredFailure || "failed")}`
|
|
51
76
|
: outcome.status === "not_run"
|
|
52
77
|
? ` ${shortenMessage(outcome.reason || "not run")}`
|
|
53
78
|
: "";
|
|
54
79
|
stdout.write(
|
|
55
|
-
`${status} ${task.serviceName} ${displayTaskType(task)} ${normalizePath(task.file)} ${duration}${detail}\n`
|
|
80
|
+
`${colorStatus(status)} ${task.serviceName} ${displayTaskType(task)} ${normalizePath(task.file)} ${dim(duration)}${detail}\n`
|
|
56
81
|
);
|
|
57
82
|
},
|
|
58
83
|
telemetry(message) {
|
|
@@ -64,7 +89,7 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
64
89
|
mode === "debug"
|
|
65
90
|
? buildDebugRunSummaryLines(results, durationMs, knownFailureIssueValidation)
|
|
66
91
|
: buildCompactRunSummaryLines(results, durationMs, knownFailureIssueValidation);
|
|
67
|
-
for (const line of lines) stdout.write(`${line}\n`);
|
|
92
|
+
for (const line of lines) stdout.write(`${colorSectionLine(line)}\n`);
|
|
68
93
|
},
|
|
69
94
|
error(message) {
|
|
70
95
|
stderr.write(`${message}\n`);
|
|
@@ -83,9 +108,18 @@ function shortenMessage(message) {
|
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
function firstFailureDetail(outcome) {
|
|
86
|
-
|
|
111
|
+
const detail = outcome.failureDetails?.[0];
|
|
112
|
+
return detail?.message || detail?.title || null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isThresholdWrapperMessage(message) {
|
|
116
|
+
return /Default runtime thresholds failed:/.test(String(message || ""));
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
function normalizePath(filePath) {
|
|
90
120
|
return String(filePath).split(path.sep).join("/");
|
|
91
121
|
}
|
|
122
|
+
|
|
123
|
+
function isHighLevelSetupOperation(operation) {
|
|
124
|
+
return operation.kind === "database-template" || operation.kind === "runtime-prepare";
|
|
125
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Writable } from "stream";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createRunReporter } from "./run-reporter.mjs";
|
|
4
|
+
|
|
5
|
+
describe("run reporter setup output", () => {
|
|
6
|
+
it("prints concise high-level setup summaries in compact mode", () => {
|
|
7
|
+
let stdout = "";
|
|
8
|
+
const reporter = createRunReporter({
|
|
9
|
+
outputMode: "compact",
|
|
10
|
+
stdout: new Writable({
|
|
11
|
+
write(chunk, _encoding, callback) {
|
|
12
|
+
stdout += chunk.toString();
|
|
13
|
+
callback();
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
reporter.setupOperationFinished({
|
|
19
|
+
serviceName: "api",
|
|
20
|
+
stage: "template",
|
|
21
|
+
kind: "database-template",
|
|
22
|
+
summary: "template rebuild",
|
|
23
|
+
status: "passed",
|
|
24
|
+
durationMs: 8_000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(stdout).toContain("RUN SETUP api template rebuild");
|
|
28
|
+
expect(stdout).toContain("8s");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not print low-level setup steps in compact mode", () => {
|
|
32
|
+
let stdout = "";
|
|
33
|
+
const reporter = createRunReporter({
|
|
34
|
+
outputMode: "compact",
|
|
35
|
+
stdout: new Writable({
|
|
36
|
+
write(chunk, _encoding, callback) {
|
|
37
|
+
stdout += chunk.toString();
|
|
38
|
+
callback();
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
reporter.setupOperationFinished({
|
|
44
|
+
serviceName: "api",
|
|
45
|
+
stage: "template:migrate:api:1",
|
|
46
|
+
kind: "setup-step",
|
|
47
|
+
summary: "sql-file: db/schema.sql",
|
|
48
|
+
status: "passed",
|
|
49
|
+
durationMs: 8_000,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(stdout).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prints concise setup failures", () => {
|
|
56
|
+
let stdout = "";
|
|
57
|
+
const reporter = createRunReporter({
|
|
58
|
+
outputMode: "compact",
|
|
59
|
+
stdout: new Writable({
|
|
60
|
+
write(chunk, _encoding, callback) {
|
|
61
|
+
stdout += chunk.toString();
|
|
62
|
+
callback();
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
reporter.setupOperationFinished({
|
|
68
|
+
serviceName: "api",
|
|
69
|
+
stage: "runtime:prepare",
|
|
70
|
+
kind: "runtime-prepare",
|
|
71
|
+
summary: "runtime prepare",
|
|
72
|
+
status: "failed",
|
|
73
|
+
durationMs: 1_200,
|
|
74
|
+
error: "Command failed with exit code 1: node scripts/fail-prepare.mjs",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(stdout).toContain("FAIL SETUP api runtime:prepare");
|
|
78
|
+
expect(stdout).toContain("Command failed with exit code 1");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
1
3
|
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
4
|
import { Box, Text, useApp, useInput } from "ink";
|
|
3
5
|
import { formatDuration } from "../../runner/formatting.mjs";
|
|
4
|
-
import { formatFileDetail,
|
|
6
|
+
import { formatFileDetail, loadCurrentRunArtifact } from "../viewer.mjs";
|
|
5
7
|
|
|
6
8
|
export function WatchApp({ productDir, serviceFilter = null }) {
|
|
7
9
|
const { exit } = useApp();
|
|
8
10
|
const [artifact, setArtifact] = useState(() => safeLoadArtifact(productDir));
|
|
9
|
-
const [
|
|
11
|
+
const [view, setView] = useState("files");
|
|
12
|
+
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
|
13
|
+
const [selectedSetupIndex, setSelectedSetupIndex] = useState(0);
|
|
10
14
|
|
|
11
15
|
const files = useMemo(() => collectFiles(artifact, serviceFilter), [artifact, serviceFilter]);
|
|
12
|
-
const
|
|
16
|
+
const setupOperations = useMemo(
|
|
17
|
+
() => collectSetupOperations(artifact, serviceFilter),
|
|
18
|
+
[artifact, serviceFilter]
|
|
19
|
+
);
|
|
20
|
+
const selectedFile = files[Math.min(selectedFileIndex, Math.max(0, files.length - 1))] || null;
|
|
21
|
+
const selectedSetup =
|
|
22
|
+
setupOperations[Math.min(selectedSetupIndex, Math.max(0, setupOperations.length - 1))] || null;
|
|
13
23
|
|
|
14
24
|
useEffect(() => {
|
|
15
25
|
const timer = setInterval(() => {
|
|
@@ -18,6 +28,14 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
18
28
|
return () => clearInterval(timer);
|
|
19
29
|
}, [productDir]);
|
|
20
30
|
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setSelectedFileIndex((current) => Math.min(current, Math.max(0, files.length - 1)));
|
|
33
|
+
}, [files.length]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setSelectedSetupIndex((current) => Math.min(current, Math.max(0, setupOperations.length - 1)));
|
|
37
|
+
}, [setupOperations.length]);
|
|
38
|
+
|
|
21
39
|
useInput((input, key) => {
|
|
22
40
|
if (input === "q") {
|
|
23
41
|
exit();
|
|
@@ -27,12 +45,27 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
27
45
|
setArtifact(safeLoadArtifact(productDir));
|
|
28
46
|
return;
|
|
29
47
|
}
|
|
48
|
+
if (key.tab || input === "s") {
|
|
49
|
+
setView((current) => {
|
|
50
|
+
if (current === "files" && setupOperations.length > 0) return "setup";
|
|
51
|
+
return "files";
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
30
55
|
if (key.downArrow) {
|
|
31
|
-
|
|
56
|
+
if (view === "setup") {
|
|
57
|
+
setSelectedSetupIndex((current) => Math.min(current + 1, Math.max(0, setupOperations.length - 1)));
|
|
58
|
+
} else {
|
|
59
|
+
setSelectedFileIndex((current) => Math.min(current + 1, Math.max(0, files.length - 1)));
|
|
60
|
+
}
|
|
32
61
|
return;
|
|
33
62
|
}
|
|
34
63
|
if (key.upArrow) {
|
|
35
|
-
|
|
64
|
+
if (view === "setup") {
|
|
65
|
+
setSelectedSetupIndex((current) => Math.max(0, current - 1));
|
|
66
|
+
} else {
|
|
67
|
+
setSelectedFileIndex((current) => Math.max(0, current - 1));
|
|
68
|
+
}
|
|
36
69
|
}
|
|
37
70
|
});
|
|
38
71
|
|
|
@@ -46,7 +79,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
46
79
|
createElement(
|
|
47
80
|
Text,
|
|
48
81
|
null,
|
|
49
|
-
`testkit watch · q quit · r reload · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
82
|
+
`testkit watch · q quit · r reload · tab toggle · ${artifact.run.status} · ${formatDuration(artifact.run.durationMs)}`
|
|
50
83
|
),
|
|
51
84
|
createElement(
|
|
52
85
|
Box,
|
|
@@ -54,22 +87,21 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
54
87
|
createElement(
|
|
55
88
|
Box,
|
|
56
89
|
{ width: "40%", flexDirection: "column", marginRight: 2 },
|
|
57
|
-
createElement(Text, null, "Files"),
|
|
58
|
-
...
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Text,
|
|
62
|
-
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
63
|
-
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
64
|
-
);
|
|
65
|
-
})
|
|
90
|
+
createElement(Text, null, view === "setup" ? "Setup" : "Files"),
|
|
91
|
+
...(view === "setup"
|
|
92
|
+
? renderSetupEntries(setupOperations, selectedSetupIndex)
|
|
93
|
+
: renderFileEntries(files, selectedFileIndex))
|
|
66
94
|
),
|
|
67
95
|
createElement(
|
|
68
96
|
Box,
|
|
69
97
|
{ width: "60%", flexDirection: "column" },
|
|
70
98
|
createElement(Text, null, "Details"),
|
|
71
|
-
...(
|
|
72
|
-
?
|
|
99
|
+
...(view === "setup"
|
|
100
|
+
? formatSetupDetail(productDir, selectedSetup)
|
|
101
|
+
.slice(0, 28)
|
|
102
|
+
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
103
|
+
: selectedFile
|
|
104
|
+
? formatFileDetail(productDir, artifact, selectedFile, { logTail: 8 })
|
|
73
105
|
.slice(0, 28)
|
|
74
106
|
.map((line, index) => createElement(Text, { key: `${index}:${line}` }, line))
|
|
75
107
|
: [createElement(Text, { key: "empty" }, "No file results")])
|
|
@@ -80,7 +112,7 @@ export function WatchApp({ productDir, serviceFilter = null }) {
|
|
|
80
112
|
|
|
81
113
|
function safeLoadArtifact(productDir) {
|
|
82
114
|
try {
|
|
83
|
-
return
|
|
115
|
+
return loadCurrentRunArtifact(productDir);
|
|
84
116
|
} catch {
|
|
85
117
|
return null;
|
|
86
118
|
}
|
|
@@ -102,3 +134,87 @@ function collectFiles(runArtifact, serviceFilter) {
|
|
|
102
134
|
left.file.path.localeCompare(right.file.path)
|
|
103
135
|
);
|
|
104
136
|
}
|
|
137
|
+
|
|
138
|
+
function renderFileEntries(files, selectedIndex) {
|
|
139
|
+
return files.map((entry, index) => {
|
|
140
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
141
|
+
return createElement(
|
|
142
|
+
Text,
|
|
143
|
+
{ key: `${entry.service.name}:${entry.file.path}` },
|
|
144
|
+
`${prefix} ${entry.file.status.toUpperCase().padEnd(7)} ${entry.file.path}`
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectSetupOperations(runArtifact, serviceFilter) {
|
|
150
|
+
if (!runArtifact) return [];
|
|
151
|
+
return [...(runArtifact.setup?.operations || [])]
|
|
152
|
+
.filter((entry) => !serviceFilter || entry.serviceName === serviceFilter)
|
|
153
|
+
.sort((left, right) => {
|
|
154
|
+
return (
|
|
155
|
+
setupStatusRank(left.status) - setupStatusRank(right.status) ||
|
|
156
|
+
String(left.serviceName || "").localeCompare(String(right.serviceName || "")) ||
|
|
157
|
+
String(left.startedAt || "").localeCompare(String(right.startedAt || "")) ||
|
|
158
|
+
String(left.stage || "").localeCompare(String(right.stage || ""))
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function renderSetupEntries(operations, selectedIndex) {
|
|
164
|
+
return operations.map((entry, index) => {
|
|
165
|
+
const prefix = index === selectedIndex ? ">" : " ";
|
|
166
|
+
const duration = entry.durationMs == null ? "" : ` ${formatDuration(entry.durationMs)}`;
|
|
167
|
+
return createElement(
|
|
168
|
+
Text,
|
|
169
|
+
{ key: entry.id },
|
|
170
|
+
`${prefix} ${String(entry.status || "").toUpperCase().padEnd(7)} ${entry.serviceName} ${entry.stage}${duration}`
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatSetupDetail(productDir, operation) {
|
|
176
|
+
if (!operation) return ["No setup operations"];
|
|
177
|
+
const lines = [
|
|
178
|
+
`Service: ${operation.serviceName}`,
|
|
179
|
+
`Stage: ${operation.stage}`,
|
|
180
|
+
`Status: ${operation.status}`,
|
|
181
|
+
];
|
|
182
|
+
if (operation.durationMs != null) {
|
|
183
|
+
lines.push(`Duration: ${formatDuration(operation.durationMs)}`);
|
|
184
|
+
}
|
|
185
|
+
if (operation.summary) {
|
|
186
|
+
lines.push(`Summary: ${operation.summary}`);
|
|
187
|
+
}
|
|
188
|
+
if (operation.error) {
|
|
189
|
+
lines.push(`Error: ${operation.error}`);
|
|
190
|
+
}
|
|
191
|
+
if (operation.logRef?.path) {
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push("Log:");
|
|
194
|
+
lines.push(` ${operation.logRef.path}`);
|
|
195
|
+
const absolutePath = path.join(productDir, operation.logRef.path);
|
|
196
|
+
for (const line of readTailSafe(absolutePath, 12)) {
|
|
197
|
+
lines.push(` ${line}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readTailSafe(absolutePath, maxLines) {
|
|
204
|
+
try {
|
|
205
|
+
return fs.readFileSync(absolutePath, "utf8")
|
|
206
|
+
.split(/\r?\n/)
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.slice(-maxLines);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setupStatusRank(status) {
|
|
215
|
+
if (status === "failed") return 1;
|
|
216
|
+
if (status === "running") return 2;
|
|
217
|
+
if (status === "passed") return 3;
|
|
218
|
+
if (status === "cached") return 4;
|
|
219
|
+
return 5;
|
|
220
|
+
}
|