@elench/testkit 0.1.81 → 0.1.83
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 +64 -27
- package/lib/cli/agents/index.mjs +64 -0
- package/lib/cli/agents/investigate.mjs +75 -0
- package/lib/cli/agents/investigation-context.mjs +102 -0
- package/lib/cli/agents/investigation-context.test.mjs +144 -0
- package/lib/cli/agents/prompt-builder.mjs +25 -0
- package/lib/cli/agents/providers/claude.mjs +74 -0
- package/lib/cli/agents/providers/claude.test.mjs +95 -0
- package/lib/cli/agents/providers/codex.mjs +83 -0
- package/lib/cli/agents/providers/codex.test.mjs +93 -0
- package/lib/cli/agents/providers/shared.mjs +134 -0
- package/lib/cli/command-helpers.mjs +53 -25
- package/lib/cli/command-helpers.test.mjs +122 -0
- package/lib/cli/commands/investigate.mjs +87 -0
- package/lib/cli/commands/investigate.test.mjs +83 -0
- package/lib/cli/entrypoint.mjs +3 -0
- package/lib/cli/presentation/colors.mjs +12 -0
- package/lib/cli/presentation/events-reporter.mjs +135 -0
- package/lib/cli/presentation/events-reporter.test.mjs +73 -0
- package/lib/cli/presentation/summary-box.mjs +11 -11
- package/lib/cli/presentation/summary-box.test.mjs +17 -0
- package/lib/cli/presentation/tree-reporter.mjs +159 -0
- package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
- package/lib/cli/tui/run-app.mjs +1 -0
- package/lib/cli/tui/run-session-app.mjs +370 -0
- package/lib/cli/tui/run-session-app.test.mjs +50 -0
- package/lib/cli/tui/run-session-state.mjs +481 -0
- package/lib/cli/tui/run-tree-state.mjs +1 -0
- package/lib/cli/tui/run-tree-state.test.mjs +324 -0
- package/lib/config-api/auth-fixtures.mjs +767 -0
- package/lib/config-api/index.d.ts +92 -108
- package/lib/config-api/index.mjs +22 -12
- package/lib/config-api/index.test.mjs +103 -210
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +34 -10
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +177 -27
- package/lib/runtime/index.mjs +68 -3
- package/lib/runtime-src/k6/http-assertions.js +31 -1
- package/lib/runtime-src/k6/http-checks.js +120 -0
- package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
- package/lib/runtime-src/k6/http-suite-runtime.js +151 -0
- package/lib/runtime-src/k6/http.js +285 -56
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- package/lib/runtime-src/k6/scenario-suite.js +13 -110
- package/lib/runtime-src/k6/suite.js +13 -107
- package/lib/runtime-src/shared/error-body.mjs +42 -0
- package/lib/runtime-src/shared/http-parsing.mjs +68 -0
- package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/lib/config-api/profiles.mjs +0 -640
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../command-helpers.mjs";
|
|
3
|
+
import { loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
|
|
4
|
+
import { runInteractiveInvestigation, startHostedInvestigation } from "../agents/investigate.mjs";
|
|
5
|
+
|
|
6
|
+
export default class InvestigateCommand extends Command {
|
|
7
|
+
static summary = "Investigate a failed file from the latest run with Codex or Claude";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
file: Args.string({
|
|
13
|
+
description: "Optional file path; defaults to the first failed file",
|
|
14
|
+
required: false,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
static flags = {
|
|
19
|
+
...sharedFlags,
|
|
20
|
+
provider: Flags.string({
|
|
21
|
+
description: "Agent provider to use",
|
|
22
|
+
options: ["auto", "claude", "codex"],
|
|
23
|
+
default: "auto",
|
|
24
|
+
}),
|
|
25
|
+
message: Flags.string({
|
|
26
|
+
description: "Additional user instruction for the investigation prompt",
|
|
27
|
+
}),
|
|
28
|
+
handoff: Flags.boolean({
|
|
29
|
+
description: "Launch the provider's native interactive TUI instead of hosted output",
|
|
30
|
+
default: false,
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async run() {
|
|
35
|
+
const { args, flags } = await this.parse(InvestigateCommand);
|
|
36
|
+
const productDir = flags.dir || process.cwd();
|
|
37
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
38
|
+
const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
|
|
39
|
+
|
|
40
|
+
if (flags.handoff) {
|
|
41
|
+
const result = await runInteractiveInvestigation({
|
|
42
|
+
productDir,
|
|
43
|
+
serviceName: subject.service.name,
|
|
44
|
+
filePath: subject.file.path,
|
|
45
|
+
provider: flags.provider,
|
|
46
|
+
userMessage: flags.message || null,
|
|
47
|
+
});
|
|
48
|
+
if (!this.jsonEnabled()) {
|
|
49
|
+
this.log(`${result.provider} exited with code ${result.exitCode}`);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let finalText = "";
|
|
55
|
+
const session = startHostedInvestigation({
|
|
56
|
+
productDir,
|
|
57
|
+
serviceName: subject.service.name,
|
|
58
|
+
filePath: subject.file.path,
|
|
59
|
+
provider: flags.provider,
|
|
60
|
+
userMessage: flags.message || null,
|
|
61
|
+
onEvent: this.jsonEnabled()
|
|
62
|
+
? null
|
|
63
|
+
: (event) => {
|
|
64
|
+
if (event.type === "status" || event.type === "tool") {
|
|
65
|
+
this.log(event.type === "tool" ? `[tool] ${event.name}${event.detail ? `: ${event.detail}` : ""}` : `[status] ${event.message}`);
|
|
66
|
+
} else if (event.type === "error") {
|
|
67
|
+
this.error(event.message);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const result = await session.completion;
|
|
72
|
+
finalText = result.finalText || "";
|
|
73
|
+
|
|
74
|
+
if (!this.jsonEnabled() && finalText.trim()) {
|
|
75
|
+
this.log("");
|
|
76
|
+
this.log(finalText.trim());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
provider: result.provider,
|
|
81
|
+
exitCode: result.exitCode,
|
|
82
|
+
file: subject.file.path,
|
|
83
|
+
service: subject.service.name,
|
|
84
|
+
finalText,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const loadCurrentRunArtifactMock = vi.fn();
|
|
4
|
+
const resolveFileSubjectMock = vi.fn();
|
|
5
|
+
const startHostedInvestigationMock = vi.fn();
|
|
6
|
+
const runInteractiveInvestigationMock = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock("../viewer.mjs", () => ({
|
|
9
|
+
loadCurrentRunArtifact: loadCurrentRunArtifactMock,
|
|
10
|
+
resolveFileSubject: resolveFileSubjectMock,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../agents/investigate.mjs", () => ({
|
|
14
|
+
startHostedInvestigation: startHostedInvestigationMock,
|
|
15
|
+
runInteractiveInvestigation: runInteractiveInvestigationMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
loadCurrentRunArtifactMock.mockReset();
|
|
20
|
+
resolveFileSubjectMock.mockReset();
|
|
21
|
+
startHostedInvestigationMock.mockReset();
|
|
22
|
+
runInteractiveInvestigationMock.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function seedSubject() {
|
|
26
|
+
loadCurrentRunArtifactMock.mockReturnValue({});
|
|
27
|
+
resolveFileSubjectMock.mockReturnValue({
|
|
28
|
+
service: { name: "api" },
|
|
29
|
+
suite: { name: "users", type: "integration" },
|
|
30
|
+
file: { path: "tests/api/users.int.testkit.ts" },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("InvestigateCommand", () => {
|
|
35
|
+
it("runs hosted investigation and returns the final text", async () => {
|
|
36
|
+
seedSubject();
|
|
37
|
+
startHostedInvestigationMock.mockReturnValue({
|
|
38
|
+
completion: Promise.resolve({
|
|
39
|
+
provider: "codex",
|
|
40
|
+
exitCode: 0,
|
|
41
|
+
finalText: "Likely root cause.",
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const { default: InvestigateCommand } = await import("./investigate.mjs");
|
|
46
|
+
const result = await InvestigateCommand.run(["--json"]);
|
|
47
|
+
|
|
48
|
+
expect(startHostedInvestigationMock).toHaveBeenCalledWith(
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
productDir: process.cwd(),
|
|
51
|
+
serviceName: "api",
|
|
52
|
+
filePath: "tests/api/users.int.testkit.ts",
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
expect(result).toMatchObject({
|
|
56
|
+
provider: "codex",
|
|
57
|
+
finalText: "Likely root cause.",
|
|
58
|
+
file: "tests/api/users.int.testkit.ts",
|
|
59
|
+
service: "api",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("runs handoff investigation when requested", async () => {
|
|
64
|
+
seedSubject();
|
|
65
|
+
runInteractiveInvestigationMock.mockResolvedValue({
|
|
66
|
+
provider: "claude",
|
|
67
|
+
exitCode: 0,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const { default: InvestigateCommand } = await import("./investigate.mjs");
|
|
71
|
+
const result = await InvestigateCommand.run(["--json", "--handoff", "--provider", "claude"]);
|
|
72
|
+
|
|
73
|
+
expect(runInteractiveInvestigationMock).toHaveBeenCalledWith(
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
productDir: process.cwd(),
|
|
76
|
+
serviceName: "api",
|
|
77
|
+
filePath: "tests/api/users.int.testkit.ts",
|
|
78
|
+
provider: "claude",
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
expect(result).toMatchObject({ provider: "claude", exitCode: 0 });
|
|
82
|
+
});
|
|
83
|
+
});
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export function normalizeCliArgs(argv) {
|
|
|
11
11
|
"discover",
|
|
12
12
|
"typecheck",
|
|
13
13
|
"doctor",
|
|
14
|
+
"investigate",
|
|
14
15
|
"browser",
|
|
15
16
|
"db",
|
|
16
17
|
"help",
|
|
@@ -37,6 +38,8 @@ export function normalizeCliArgs(argv) {
|
|
|
37
38
|
"--output-mode",
|
|
38
39
|
"--tail",
|
|
39
40
|
"--log-tail",
|
|
41
|
+
"--provider",
|
|
42
|
+
"--message",
|
|
40
43
|
]);
|
|
41
44
|
const positionals = findPositionals(argv, valueFlags);
|
|
42
45
|
const firstPositional = positionals[0] || null;
|
|
@@ -35,6 +35,18 @@ export function red(text) {
|
|
|
35
35
|
return pc.red(text);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export function green(text) {
|
|
39
|
+
return pc.green(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function yellow(text) {
|
|
43
|
+
return pc.yellow(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cyan(text) {
|
|
47
|
+
return pc.cyan(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
export function muted(text) {
|
|
39
51
|
return pc.dim(text);
|
|
40
52
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export function createRunEventsReporter({ stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
2
|
+
function writeEvent(type, payload = {}) {
|
|
3
|
+
stdout.write(`${JSON.stringify({ type, ...payload })}\n`);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
outputMode: "events",
|
|
8
|
+
|
|
9
|
+
setRegressionCatalog(document) {
|
|
10
|
+
writeEvent("run.regression_catalog", {
|
|
11
|
+
configured: Boolean(document?.configured),
|
|
12
|
+
});
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
setServicePlans(plans) {
|
|
16
|
+
writeEvent("run.service_plans", {
|
|
17
|
+
services: plans.map((plan) => ({
|
|
18
|
+
service: plan.config.name,
|
|
19
|
+
skipped: Boolean(plan.skipped),
|
|
20
|
+
suites: (plan.suites || []).map((suite) => ({
|
|
21
|
+
name: suite.name,
|
|
22
|
+
type: suite.type,
|
|
23
|
+
displayType: suite.displayType,
|
|
24
|
+
framework: suite.framework,
|
|
25
|
+
files: suite.files,
|
|
26
|
+
})),
|
|
27
|
+
})),
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
setTotalFileCount(count) {
|
|
32
|
+
writeEvent("run.total_files", { total: count });
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
phaseStarted(label) {
|
|
36
|
+
writeEvent("run.phase_started", { label });
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
toolchainResolved(config, resolvedToolchain) {
|
|
40
|
+
writeEvent("run.toolchain_resolved", {
|
|
41
|
+
service: config.name,
|
|
42
|
+
runtimeLabel: config.runtimeLabel || config.name,
|
|
43
|
+
summary: resolvedToolchain.summary,
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
setupOperationFinished(operation) {
|
|
48
|
+
if (!operation) return;
|
|
49
|
+
writeEvent("run.setup_finished", {
|
|
50
|
+
service: operation.serviceName,
|
|
51
|
+
stage: operation.stage,
|
|
52
|
+
status: operation.status,
|
|
53
|
+
summary: operation.summary || null,
|
|
54
|
+
durationMs: operation.durationMs ?? null,
|
|
55
|
+
error: operation.error || null,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
localServiceStarting(config, command) {
|
|
60
|
+
writeEvent("run.local_service_starting", {
|
|
61
|
+
service: config.name,
|
|
62
|
+
runtimeLabel: config.runtimeLabel || config.name,
|
|
63
|
+
command,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
serviceSkipped(config, reason) {
|
|
68
|
+
writeEvent("run.service_skipped", {
|
|
69
|
+
service: config.name,
|
|
70
|
+
reason,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
plannedSkip(entry) {
|
|
75
|
+
writeEvent("run.task_planned_skip", {
|
|
76
|
+
service: entry.serviceName,
|
|
77
|
+
file: entry.file,
|
|
78
|
+
reason: entry.reason || null,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
taskStarted(task) {
|
|
83
|
+
writeEvent("run.task_started", {
|
|
84
|
+
service: task.serviceName,
|
|
85
|
+
file: task.file,
|
|
86
|
+
suite: task.suiteName,
|
|
87
|
+
type: task.type,
|
|
88
|
+
framework: task.framework,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
taskFinished(task, outcome) {
|
|
93
|
+
writeEvent("run.task_finished", {
|
|
94
|
+
service: task.serviceName,
|
|
95
|
+
file: task.file,
|
|
96
|
+
suite: task.suiteName,
|
|
97
|
+
type: task.type,
|
|
98
|
+
framework: task.framework,
|
|
99
|
+
outcome,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
runtimeError(task, message) {
|
|
104
|
+
writeEvent("run.runtime_error", {
|
|
105
|
+
service: task.serviceName,
|
|
106
|
+
file: task.file,
|
|
107
|
+
message,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
telemetry(message) {
|
|
112
|
+
writeEvent("run.telemetry", { message });
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
writeLine(line = "") {
|
|
116
|
+
writeEvent("run.output", { line });
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
writeDebugLine(line = "") {
|
|
120
|
+
writeEvent("run.debug", { line });
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
runSummary(results, durationMs, regressionReport) {
|
|
124
|
+
writeEvent("run.summary", {
|
|
125
|
+
results,
|
|
126
|
+
durationMs,
|
|
127
|
+
regressionReport,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
error(message) {
|
|
132
|
+
stderr.write(`${message}\n`);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Writable } from "stream";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createRunEventsReporter } from "./events-reporter.mjs";
|
|
4
|
+
|
|
5
|
+
function createCapture() {
|
|
6
|
+
let output = "";
|
|
7
|
+
const stream = new Writable({
|
|
8
|
+
write(chunk, _encoding, callback) {
|
|
9
|
+
output += chunk.toString();
|
|
10
|
+
callback();
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
stream,
|
|
15
|
+
lines() {
|
|
16
|
+
return output.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("events reporter", () => {
|
|
22
|
+
it("emits structured run lifecycle events", () => {
|
|
23
|
+
const capture = createCapture();
|
|
24
|
+
const reporter = createRunEventsReporter({ stdout: capture.stream });
|
|
25
|
+
|
|
26
|
+
reporter.setTotalFileCount(2);
|
|
27
|
+
reporter.taskStarted({
|
|
28
|
+
serviceName: "api",
|
|
29
|
+
file: "tests/api/users.int.testkit.ts",
|
|
30
|
+
suiteName: "users",
|
|
31
|
+
type: "integration",
|
|
32
|
+
framework: "k6",
|
|
33
|
+
});
|
|
34
|
+
reporter.taskFinished(
|
|
35
|
+
{
|
|
36
|
+
serviceName: "api",
|
|
37
|
+
file: "tests/api/users.int.testkit.ts",
|
|
38
|
+
suiteName: "users",
|
|
39
|
+
type: "integration",
|
|
40
|
+
framework: "k6",
|
|
41
|
+
},
|
|
42
|
+
{ failed: true, error: "status 500" }
|
|
43
|
+
);
|
|
44
|
+
reporter.runSummary([{ name: "api", failed: true }], 1200, null);
|
|
45
|
+
|
|
46
|
+
expect(capture.lines()).toEqual([
|
|
47
|
+
{ type: "run.total_files", total: 2 },
|
|
48
|
+
{
|
|
49
|
+
type: "run.task_started",
|
|
50
|
+
service: "api",
|
|
51
|
+
file: "tests/api/users.int.testkit.ts",
|
|
52
|
+
suite: "users",
|
|
53
|
+
type: "integration",
|
|
54
|
+
framework: "k6",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "run.task_finished",
|
|
58
|
+
service: "api",
|
|
59
|
+
file: "tests/api/users.int.testkit.ts",
|
|
60
|
+
suite: "users",
|
|
61
|
+
type: "integration",
|
|
62
|
+
framework: "k6",
|
|
63
|
+
outcome: { failed: true, error: "status 500" },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: "run.summary",
|
|
67
|
+
results: [{ name: "api", failed: true }],
|
|
68
|
+
durationMs: 1200,
|
|
69
|
+
regressionReport: null,
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -8,8 +8,8 @@ export function renderSummaryBox(
|
|
|
8
8
|
widthRatio = 0.55,
|
|
9
9
|
minWidth = 30,
|
|
10
10
|
maxWidth = 56,
|
|
11
|
+
minKeyWidth = 6,
|
|
11
12
|
minValueWidth = 8,
|
|
12
|
-
maxKeyWidth = 12,
|
|
13
13
|
} = {}
|
|
14
14
|
) {
|
|
15
15
|
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
@@ -17,23 +17,23 @@ export function renderSummaryBox(
|
|
|
17
17
|
const terminalWidth = getTerminalWidth(stdout, 100);
|
|
18
18
|
const preferredWidth = clamp(Math.floor(terminalWidth * widthRatio), minWidth, maxWidth);
|
|
19
19
|
const maxRenderableWidth = Math.max(minWidth, Math.min(preferredWidth, terminalWidth - 1));
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
);
|
|
24
|
-
const
|
|
25
|
-
const boxWidth = Math.max(minBoxWidth, maxRenderableWidth);
|
|
26
|
-
const valueWidth = Math.max(minValueWidth, boxWidth - keyWidth - 7);
|
|
20
|
+
const widestLabel = Math.max(...rows.map(([label]) => measureWidth(label)), minKeyWidth);
|
|
21
|
+
const boxWidth = Math.max(minWidth, maxRenderableWidth);
|
|
22
|
+
const contentWidth = Math.max(2, boxWidth - 7);
|
|
23
|
+
const keyWidth = Math.min(widestLabel, Math.max(minKeyWidth, contentWidth - minValueWidth));
|
|
24
|
+
const valueWidth = Math.max(1, contentWidth - keyWidth);
|
|
27
25
|
|
|
28
26
|
const top = `${figures.lineDownRight}${figures.line.repeat(boxWidth - 2)}${figures.lineDownLeft}`;
|
|
29
27
|
const bottom = `${figures.lineUpRight}${figures.line.repeat(boxWidth - 2)}${figures.lineUpLeft}`;
|
|
30
28
|
const rendered = [top];
|
|
31
29
|
|
|
32
30
|
for (const [label, value] of rows) {
|
|
31
|
+
const wrappedKeyLines = wrapText(label, keyWidth);
|
|
33
32
|
const wrappedValueLines = wrapText(value, valueWidth);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
33
|
+
const lineCount = Math.max(wrappedKeyLines.length, wrappedValueLines.length);
|
|
34
|
+
for (let index = 0; index < lineCount; index += 1) {
|
|
35
|
+
const keyCell = padEndVisible(wrappedKeyLines[index] ?? "", keyWidth);
|
|
36
|
+
const valueCell = padEndVisible(wrappedValueLines[index] ?? "", valueWidth);
|
|
37
37
|
rendered.push(
|
|
38
38
|
`${figures.lineVertical} ${keyCell} ${figures.lineVertical} ${valueCell} ${figures.lineVertical}`
|
|
39
39
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Writable } from "stream";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import { renderSummaryBox } from "./summary-box.mjs";
|
|
4
|
+
import { measureWidth } from "./terminal-layout.mjs";
|
|
4
5
|
|
|
5
6
|
function createStream(columns) {
|
|
6
7
|
const stream = new Writable({
|
|
@@ -40,4 +41,20 @@ describe("summary box", () => {
|
|
|
40
41
|
expect(lines.join("\n")).toContain("stale");
|
|
41
42
|
expect(lines.join("\n")).toContain("cache");
|
|
42
43
|
});
|
|
44
|
+
|
|
45
|
+
it("keeps long labels inside the box width", () => {
|
|
46
|
+
const lines = renderSummaryBox(
|
|
47
|
+
[
|
|
48
|
+
["Result", "FAILED"],
|
|
49
|
+
["New regressions", "1"],
|
|
50
|
+
],
|
|
51
|
+
{ stdout: createStream(80) }
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const boxWidth = measureWidth(lines[0]);
|
|
55
|
+
expect(lines.join("\n")).toContain("New regressions");
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
expect(measureWidth(line)).toBe(boxWidth);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
43
60
|
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import React, { createElement } from "react";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { createRunSessionState } from "../tui/run-session-state.mjs";
|
|
4
|
+
import { RunSessionApp } from "../tui/run-session-app.mjs";
|
|
5
|
+
import { suiteSelectionType } from "../../runner/suite-selection.mjs";
|
|
6
|
+
import { startHostedInvestigation } from "../agents/investigate.mjs";
|
|
7
|
+
|
|
8
|
+
export function createTreeReporter({ stdout = process.stdout, stderr = process.stderr, productDir } = {}) {
|
|
9
|
+
const sessionState = createRunSessionState();
|
|
10
|
+
let activeAgentSession = null;
|
|
11
|
+
let investigationToken = 0;
|
|
12
|
+
|
|
13
|
+
const app = render(
|
|
14
|
+
createElement(RunSessionApp, {
|
|
15
|
+
sessionState,
|
|
16
|
+
stdout,
|
|
17
|
+
productDir,
|
|
18
|
+
onInvestigate: startInvestigation,
|
|
19
|
+
onRequestClose: close,
|
|
20
|
+
onCancelInvestigation: cancelInvestigation,
|
|
21
|
+
}),
|
|
22
|
+
{ stdout, exitOnCtrlC: false }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const finalize = app.waitUntilExit();
|
|
26
|
+
|
|
27
|
+
const reporter = {
|
|
28
|
+
outputMode: "compact",
|
|
29
|
+
|
|
30
|
+
setServicePlans(plans) {
|
|
31
|
+
sessionState.initFromPlans(plans);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
setTotalFileCount(count) {
|
|
35
|
+
sessionState.setTotalFileCount(count);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
setRegressionCatalog(document) {
|
|
39
|
+
sessionState.setRegressionCatalog(document);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
serviceSkipped(config, reason) {
|
|
43
|
+
sessionState.markServiceSkipped(config.name, reason);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
plannedSkip(entry) {
|
|
47
|
+
sessionState.markPlannedSkip(entry);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
taskStarted(task, _config) {
|
|
51
|
+
const suiteKey = `${task.displayType || suiteSelectionType(task.type, task.framework)}:${task.suiteName}`;
|
|
52
|
+
sessionState.markFileRunning(task.serviceName, suiteKey, task.file);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
taskFinished(task, outcome) {
|
|
56
|
+
sessionState.markFileFinished(task, outcome);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
runtimeError(task, message) {
|
|
60
|
+
sessionState.markRuntimeError(task, message);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
setupOperationFinished(_operation) {
|
|
64
|
+
// tree handles this implicitly through phase
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
phaseStarted(label) {
|
|
68
|
+
sessionState.setPhase(label);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
toolchainResolved() {},
|
|
72
|
+
localServiceStarting() {},
|
|
73
|
+
writeLine() {},
|
|
74
|
+
writeDebugLine() {},
|
|
75
|
+
telemetry() {},
|
|
76
|
+
|
|
77
|
+
runSummary(results, durationMs, regressionReport) {
|
|
78
|
+
sessionState.finish(results, durationMs, regressionReport);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
error(message) {
|
|
82
|
+
stderr.write(`${message}\n`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
reporter,
|
|
88
|
+
finalize,
|
|
89
|
+
close,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
async function startInvestigation({ provider = "auto", userMessage } = {}) {
|
|
93
|
+
const snapshot = sessionState.getSnapshot();
|
|
94
|
+
if (!snapshot.selectedFailure) {
|
|
95
|
+
sessionState.setNotice("No failed file is selected for investigation.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (activeAgentSession) {
|
|
99
|
+
sessionState.setNotice("An investigation is already running.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const token = ++investigationToken;
|
|
104
|
+
sessionState.beginInvestigation({ provider, userMessage });
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
activeAgentSession = startHostedInvestigation({
|
|
108
|
+
productDir,
|
|
109
|
+
serviceName: snapshot.selectedFailure.serviceName,
|
|
110
|
+
filePath: snapshot.selectedFailure.filePath,
|
|
111
|
+
provider,
|
|
112
|
+
userMessage,
|
|
113
|
+
onEvent(event) {
|
|
114
|
+
if (token !== investigationToken) return;
|
|
115
|
+
sessionState.appendAgentEvent(event);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const result = await activeAgentSession.completion;
|
|
119
|
+
if (token !== investigationToken) return;
|
|
120
|
+
activeAgentSession = null;
|
|
121
|
+
if (result.cancelled) {
|
|
122
|
+
sessionState.cancelAgentSession("Cancelled investigation.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (result.exitCode !== 0 && !result.finalText) {
|
|
126
|
+
sessionState.failAgentSession(result.stderr || `Agent exited with code ${result.exitCode}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
sessionState.completeAgentSession({
|
|
130
|
+
finalText: result.finalText,
|
|
131
|
+
exitCode: result.exitCode,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (token !== investigationToken) return;
|
|
135
|
+
activeAgentSession = null;
|
|
136
|
+
sessionState.failAgentSession(error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cancelInvestigation() {
|
|
141
|
+
if (!activeAgentSession) {
|
|
142
|
+
sessionState.returnToSummary();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
investigationToken += 1;
|
|
146
|
+
activeAgentSession.cancel();
|
|
147
|
+
activeAgentSession = null;
|
|
148
|
+
sessionState.cancelAgentSession("Cancelled investigation.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function close() {
|
|
152
|
+
if (activeAgentSession) {
|
|
153
|
+
investigationToken += 1;
|
|
154
|
+
activeAgentSession.cancel();
|
|
155
|
+
activeAgentSession = null;
|
|
156
|
+
}
|
|
157
|
+
app.unmount();
|
|
158
|
+
}
|
|
159
|
+
}
|