@elench/testkit 0.1.82 → 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.
Files changed (50) hide show
  1. package/README.md +37 -7
  2. package/lib/cli/agents/index.mjs +64 -0
  3. package/lib/cli/agents/investigate.mjs +75 -0
  4. package/lib/cli/agents/investigation-context.mjs +102 -0
  5. package/lib/cli/agents/investigation-context.test.mjs +144 -0
  6. package/lib/cli/agents/prompt-builder.mjs +25 -0
  7. package/lib/cli/agents/providers/claude.mjs +74 -0
  8. package/lib/cli/agents/providers/claude.test.mjs +95 -0
  9. package/lib/cli/agents/providers/codex.mjs +83 -0
  10. package/lib/cli/agents/providers/codex.test.mjs +93 -0
  11. package/lib/cli/agents/providers/shared.mjs +134 -0
  12. package/lib/cli/command-helpers.mjs +53 -25
  13. package/lib/cli/command-helpers.test.mjs +122 -0
  14. package/lib/cli/commands/investigate.mjs +87 -0
  15. package/lib/cli/commands/investigate.test.mjs +83 -0
  16. package/lib/cli/entrypoint.mjs +3 -0
  17. package/lib/cli/presentation/colors.mjs +12 -0
  18. package/lib/cli/presentation/events-reporter.mjs +135 -0
  19. package/lib/cli/presentation/events-reporter.test.mjs +73 -0
  20. package/lib/cli/presentation/summary-box.mjs +11 -11
  21. package/lib/cli/presentation/summary-box.test.mjs +17 -0
  22. package/lib/cli/presentation/tree-reporter.mjs +159 -0
  23. package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
  24. package/lib/cli/tui/run-app.mjs +1 -0
  25. package/lib/cli/tui/run-session-app.mjs +370 -0
  26. package/lib/cli/tui/run-session-app.test.mjs +50 -0
  27. package/lib/cli/tui/run-session-state.mjs +481 -0
  28. package/lib/cli/tui/run-tree-state.mjs +1 -0
  29. package/lib/cli/tui/run-tree-state.test.mjs +324 -0
  30. package/lib/config-api/auth-fixtures.mjs +15 -10
  31. package/lib/config-api/index.test.mjs +54 -0
  32. package/lib/discovery/index.mjs +1 -1
  33. package/lib/index.d.ts +5 -1
  34. package/lib/runner/orchestrator.mjs +1 -0
  35. package/lib/runtime/index.d.ts +138 -5
  36. package/lib/runtime/index.mjs +68 -2
  37. package/lib/runtime-src/k6/http-assertions.js +31 -1
  38. package/lib/runtime-src/k6/http-checks.js +120 -0
  39. package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
  40. package/lib/runtime-src/k6/http-suite-runtime.js +5 -1
  41. package/lib/runtime-src/k6/http.js +213 -23
  42. package/lib/runtime-src/k6/http.test.mjs +205 -0
  43. package/lib/runtime-src/shared/error-body.mjs +42 -0
  44. package/lib/runtime-src/shared/http-parsing.mjs +68 -0
  45. package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
  46. package/node_modules/@elench/next-analysis/package.json +1 -1
  47. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  48. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  49. package/node_modules/@elench/ts-analysis/package.json +1 -1
  50. package/package.json +5 -5
@@ -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 keyWidth = Math.min(
21
- maxKeyWidth,
22
- Math.max(...rows.map(([label]) => measureWidth(label)), 6)
23
- );
24
- const minBoxWidth = keyWidth + minValueWidth + 7;
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
- for (let index = 0; index < wrappedValueLines.length; index += 1) {
35
- const keyCell = index === 0 ? padEndVisible(label, keyWidth) : " ".repeat(keyWidth);
36
- const valueCell = padEndVisible(wrappedValueLines[index], valueWidth);
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
+ }
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const renderMock = vi.fn();
4
+ const startHostedInvestigationMock = vi.fn();
5
+ let capturedElement = null;
6
+ let finalizeResolve = null;
7
+ let unmountSpy;
8
+
9
+ vi.mock("ink", () => ({
10
+ render: renderMock,
11
+ }));
12
+
13
+ vi.mock("../agents/investigate.mjs", () => ({
14
+ startHostedInvestigation: startHostedInvestigationMock,
15
+ }));
16
+
17
+ function flush() {
18
+ return new Promise((resolve) => setTimeout(resolve, 0));
19
+ }
20
+
21
+ beforeEach(() => {
22
+ capturedElement = null;
23
+ unmountSpy = vi.fn(() => finalizeResolve?.());
24
+ renderMock.mockImplementation((element) => {
25
+ capturedElement = element;
26
+ return {
27
+ waitUntilExit() {
28
+ return new Promise((resolve) => {
29
+ finalizeResolve = resolve;
30
+ });
31
+ },
32
+ unmount: unmountSpy,
33
+ };
34
+ });
35
+ });
36
+
37
+ afterEach(() => {
38
+ renderMock.mockReset();
39
+ startHostedInvestigationMock.mockReset();
40
+ finalizeResolve = null;
41
+ });
42
+
43
+ function getSessionState() {
44
+ return capturedElement.props.sessionState;
45
+ }
46
+
47
+ function seedFailure(reporter) {
48
+ reporter.setServicePlans([
49
+ {
50
+ config: { name: "api" },
51
+ skipped: false,
52
+ suites: [
53
+ {
54
+ name: "users",
55
+ type: "integration",
56
+ displayType: "int",
57
+ framework: "k6",
58
+ files: ["tests/api/users.int.testkit.ts"],
59
+ },
60
+ ],
61
+ },
62
+ ]);
63
+ reporter.taskFinished(
64
+ {
65
+ serviceName: "api",
66
+ type: "integration",
67
+ displayType: "int",
68
+ framework: "k6",
69
+ suiteName: "users",
70
+ file: "tests/api/users.int.testkit.ts",
71
+ },
72
+ { failed: true, error: "status 500", durationMs: 1000, failureDetails: [] }
73
+ );
74
+ }
75
+
76
+ describe("tree reporter", () => {
77
+ it("keeps the session mounted after run summary until close is requested", async () => {
78
+ const { createTreeReporter } = await import("./tree-reporter.mjs");
79
+ const tree = createTreeReporter({ productDir: "/tmp/project" });
80
+
81
+ tree.reporter.runSummary([], 1000, null);
82
+
83
+ let settled = false;
84
+ tree.finalize.then(() => {
85
+ settled = true;
86
+ });
87
+ await flush();
88
+
89
+ expect(settled).toBe(false);
90
+ tree.close();
91
+ await tree.finalize;
92
+ expect(unmountSpy).toHaveBeenCalled();
93
+ });
94
+
95
+ it("streams hosted investigation events into the session state", async () => {
96
+ const { createTreeReporter } = await import("./tree-reporter.mjs");
97
+ startHostedInvestigationMock.mockImplementation(({ onEvent }) => {
98
+ onEvent({ type: "start" });
99
+ onEvent({ type: "status", message: "Inspecting repository" });
100
+ onEvent({ type: "delta", text: "Likely root cause." });
101
+ return {
102
+ cancel: vi.fn(),
103
+ completion: Promise.resolve({
104
+ provider: "codex",
105
+ exitCode: 0,
106
+ finalText: "Likely root cause.",
107
+ cancelled: false,
108
+ }),
109
+ };
110
+ });
111
+
112
+ const tree = createTreeReporter({ productDir: "/tmp/project" });
113
+ seedFailure(tree.reporter);
114
+
115
+ await capturedElement.props.onInvestigate({
116
+ provider: "codex",
117
+ userMessage: "Investigate the selected failure",
118
+ });
119
+
120
+ const snapshot = getSessionState().getSnapshot();
121
+ expect(startHostedInvestigationMock).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ productDir: "/tmp/project",
124
+ serviceName: "api",
125
+ filePath: "tests/api/users.int.testkit.ts",
126
+ })
127
+ );
128
+ expect(snapshot.agentSession.status).toBe("complete");
129
+ expect(snapshot.agentSession.entries.some((entry) => entry.kind === "assistant")).toBe(true);
130
+ });
131
+
132
+ it("cancels an active investigation and unmounts cleanly on close", async () => {
133
+ const { createTreeReporter } = await import("./tree-reporter.mjs");
134
+ const cancel = vi.fn();
135
+ let resolveCompletion;
136
+ startHostedInvestigationMock.mockReturnValue({
137
+ cancel,
138
+ completion: new Promise((resolve) => {
139
+ resolveCompletion = resolve;
140
+ }),
141
+ });
142
+
143
+ const tree = createTreeReporter({ productDir: "/tmp/project" });
144
+ seedFailure(tree.reporter);
145
+
146
+ const pending = capturedElement.props.onInvestigate({
147
+ provider: "claude",
148
+ userMessage: "Investigate the selected failure",
149
+ });
150
+ await flush();
151
+ capturedElement.props.onCancelInvestigation();
152
+ resolveCompletion({
153
+ provider: "claude",
154
+ exitCode: 130,
155
+ finalText: "",
156
+ cancelled: true,
157
+ });
158
+ await pending;
159
+
160
+ expect(cancel).toHaveBeenCalled();
161
+ expect(getSessionState().getSnapshot().notice).toContain("Cancelled");
162
+ tree.close();
163
+ await tree.finalize;
164
+ expect(unmountSpy).toHaveBeenCalled();
165
+ });
166
+ });
@@ -0,0 +1 @@
1
+ export { RunSessionApp as RunApp } from "./run-session-app.mjs";