@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
@@ -0,0 +1,83 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { execa } from "execa";
5
+ import {
6
+ buildErrorEvent,
7
+ buildStatusEvent,
8
+ buildToolEvent,
9
+ createHostedSessionRunner,
10
+ extractTextFragments,
11
+ readTextFileIfPresent,
12
+ } from "./shared.mjs";
13
+
14
+ export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
15
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
16
+ const outputFile = path.join(tempDir, "final-message.txt");
17
+ const args = ["exec", "--json", "-o", outputFile];
18
+
19
+ if (purpose === "investigate") {
20
+ args.push("-s", "read-only", "-a", "never");
21
+ }
22
+
23
+ args.push(prompt);
24
+
25
+ const child = execa("codex", args, {
26
+ cwd,
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ reject: false,
30
+ });
31
+
32
+ const session = createHostedSessionRunner({
33
+ provider: "codex",
34
+ child,
35
+ onEvent,
36
+ parsePayload: parseCodexPayload,
37
+ readFinalText(result) {
38
+ return readTextFileIfPresent(outputFile) || result.stdout || null;
39
+ },
40
+ });
41
+
42
+ const completion = session.completion.finally(() => {
43
+ fs.rmSync(tempDir, { recursive: true, force: true });
44
+ });
45
+
46
+ return {
47
+ completion,
48
+ cancel: session.cancel,
49
+ };
50
+ }
51
+
52
+ function parseCodexPayload(payload) {
53
+ const events = [];
54
+ if (!payload || typeof payload !== "object") return events;
55
+ const type = payload.type || payload.event || payload.kind || null;
56
+ const errorMessage = payload.error?.message || payload.error || null;
57
+ if (errorMessage) {
58
+ const event = buildErrorEvent(errorMessage);
59
+ if (event) events.push(event);
60
+ return events;
61
+ }
62
+
63
+ if (type && /(tool|command|patch|exec)/i.test(type)) {
64
+ const event = buildToolEvent(
65
+ payload.name || payload.tool_name || payload.command || type,
66
+ payload.detail || payload.summary || payload.status || null
67
+ );
68
+ if (event) events.push(event);
69
+ return events;
70
+ }
71
+
72
+ const fragments = [...new Set(extractTextFragments(payload, []))];
73
+ if (fragments.length > 0) {
74
+ for (const fragment of fragments) {
75
+ events.push({ type: "delta", text: fragment });
76
+ }
77
+ return events;
78
+ }
79
+
80
+ const statusEvent = buildStatusEvent(type ? `Codex event: ${type}` : JSON.stringify(payload));
81
+ if (statusEvent) events.push(statusEvent);
82
+ return events;
83
+ }
@@ -0,0 +1,93 @@
1
+ import { PassThrough } from "stream";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const execaMock = vi.fn();
5
+
6
+ vi.mock("execa", () => ({
7
+ execa: execaMock,
8
+ }));
9
+
10
+ function createChildHandle() {
11
+ const stdout = new PassThrough();
12
+ const stderr = new PassThrough();
13
+ let resolveChild;
14
+ const promise = new Promise((resolve) => {
15
+ resolveChild = resolve;
16
+ });
17
+ promise.stdout = stdout;
18
+ promise.stderr = stderr;
19
+ promise.kill = vi.fn(() => {
20
+ stdout.end();
21
+ stderr.end();
22
+ });
23
+ return {
24
+ child: promise,
25
+ finish(result = {}) {
26
+ stdout.end();
27
+ stderr.end();
28
+ resolveChild({
29
+ exitCode: 0,
30
+ stdout: "",
31
+ stderr: "",
32
+ ...result,
33
+ });
34
+ },
35
+ };
36
+ }
37
+
38
+ afterEach(() => {
39
+ execaMock.mockReset();
40
+ });
41
+
42
+ describe("Codex hosted session", () => {
43
+ it("normalizes streamed tool, delta, and final output", async () => {
44
+ const { startCodexHostedSession } = await import("./codex.mjs");
45
+ const handle = createChildHandle();
46
+ execaMock.mockReturnValue(handle.child);
47
+ const events = [];
48
+
49
+ const session = startCodexHostedSession({
50
+ cwd: "/tmp/project",
51
+ prompt: "Investigate this failure",
52
+ onEvent(event) {
53
+ events.push(event);
54
+ },
55
+ });
56
+
57
+ handle.child.stdout.write(`${JSON.stringify({ type: "command", command: "rg", status: "done" })}\n`);
58
+ handle.child.stdout.write(`${JSON.stringify({ type: "assistant_delta", text: "Check the migration step." })}\n`);
59
+ handle.finish({ exitCode: 0, stdout: "Check the migration step." });
60
+
61
+ const result = await session.completion;
62
+
63
+ expect(execaMock).toHaveBeenCalledWith(
64
+ "codex",
65
+ expect.arrayContaining(["exec", "--json", "-s", "read-only", "-a", "never"]),
66
+ expect.objectContaining({ cwd: "/tmp/project", reject: false })
67
+ );
68
+ expect(events[0]).toMatchObject({ provider: "codex", type: "start" });
69
+ expect(events).toContainEqual(expect.objectContaining({ provider: "codex", type: "tool", name: "rg" }));
70
+ expect(events).toContainEqual(expect.objectContaining({ provider: "codex", type: "delta", text: "Check the migration step." }));
71
+ expect(events.at(-2)).toMatchObject({ provider: "codex", type: "final", text: "Check the migration step." });
72
+ expect(events.at(-1)).toMatchObject({ provider: "codex", type: "exit", code: 0 });
73
+ expect(result.finalText).toBe("Check the migration step.");
74
+ });
75
+
76
+ it("cancels the child process", async () => {
77
+ const { startCodexHostedSession } = await import("./codex.mjs");
78
+ const handle = createChildHandle();
79
+ execaMock.mockReturnValue(handle.child);
80
+
81
+ const session = startCodexHostedSession({
82
+ cwd: "/tmp/project",
83
+ prompt: "Investigate this failure",
84
+ });
85
+
86
+ session.cancel();
87
+ handle.finish({ exitCode: 130 });
88
+ const result = await session.completion;
89
+
90
+ expect(handle.child.kill).toHaveBeenCalledWith("SIGTERM");
91
+ expect(result.cancelled).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,134 @@
1
+ import fs from "fs";
2
+ import readline from "readline";
3
+
4
+ export function createHostedSessionRunner({ provider, child, onEvent, parsePayload, readFinalText } = {}) {
5
+ let cancelled = false;
6
+ let settled = false;
7
+ let assistantText = "";
8
+
9
+ const emit = (event) => {
10
+ if (event?.type === "delta" || event?.type === "final") {
11
+ assistantText += event.text || "";
12
+ }
13
+ if (typeof onEvent === "function" && event) onEvent({ provider, ...event });
14
+ };
15
+
16
+ emit({ type: "start" });
17
+
18
+ const stdoutReader = readline.createInterface({ input: child.stdout });
19
+ stdoutReader.on("line", (line) => {
20
+ const parsed = tryParseJson(line);
21
+ if (parsed == null) {
22
+ emit({ type: "status", message: line });
23
+ return;
24
+ }
25
+ const events = parsePayload ? parsePayload(parsed) : [];
26
+ if (!Array.isArray(events) || events.length === 0) return;
27
+ for (const event of events) emit(event);
28
+ });
29
+
30
+ const stderrReader = readline.createInterface({ input: child.stderr });
31
+ stderrReader.on("line", (line) => {
32
+ emit({ type: "status", message: line });
33
+ });
34
+
35
+ const completion = (async () => {
36
+ const result = await child;
37
+ const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
38
+ if (finalText) emit({ type: "final", text: finalText });
39
+ emit({ type: "exit", code: result.exitCode ?? 0 });
40
+ settled = true;
41
+ return {
42
+ provider,
43
+ exitCode: result.exitCode ?? 0,
44
+ stdout: result.stdout || "",
45
+ stderr: result.stderr || "",
46
+ finalText: finalText || result.stdout || "",
47
+ cancelled,
48
+ };
49
+ })();
50
+
51
+ return {
52
+ completion,
53
+ cancel() {
54
+ cancelled = true;
55
+ if (settled) return;
56
+ try {
57
+ child.kill("SIGTERM");
58
+ } catch {
59
+ // Ignore cancellation races.
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ export function tryParseJson(line) {
66
+ const normalized = String(line || "").trim();
67
+ if (!normalized) return null;
68
+ try {
69
+ return JSON.parse(normalized);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export function buildToolEvent(name, detail = null) {
76
+ if (!name) return null;
77
+ return {
78
+ type: "tool",
79
+ name: String(name),
80
+ ...(detail ? { detail: String(detail) } : {}),
81
+ };
82
+ }
83
+
84
+ export function buildStatusEvent(message) {
85
+ if (!message) return null;
86
+ return { type: "status", message: String(message) };
87
+ }
88
+
89
+ export function buildErrorEvent(message) {
90
+ if (!message) return null;
91
+ return { type: "error", message: String(message) };
92
+ }
93
+
94
+ export function extractTextFragments(payload, fragments = [], depth = 0) {
95
+ if (payload == null || depth > 5 || fragments.length >= 12) return fragments;
96
+ if (typeof payload === "string") {
97
+ const normalized = payload.trim();
98
+ if (normalized) fragments.push(normalized);
99
+ return fragments;
100
+ }
101
+ if (Array.isArray(payload)) {
102
+ for (const entry of payload) {
103
+ extractTextFragments(entry, fragments, depth + 1);
104
+ if (fragments.length >= 12) break;
105
+ }
106
+ return fragments;
107
+ }
108
+ if (typeof payload !== "object") return fragments;
109
+
110
+ const preferredKeys = ["delta", "text", "content", "message", "output_text", "summary"];
111
+ let matchedPreferredKey = false;
112
+ for (const key of preferredKeys) {
113
+ if (!(key in payload)) continue;
114
+ matchedPreferredKey = true;
115
+ extractTextFragments(payload[key], fragments, depth + 1);
116
+ if (fragments.length >= 12) return fragments;
117
+ }
118
+ if (matchedPreferredKey && fragments.length > 0) return fragments;
119
+
120
+ for (const value of Object.values(payload)) {
121
+ extractTextFragments(value, fragments, depth + 1);
122
+ if (fragments.length >= 12) break;
123
+ }
124
+ return fragments;
125
+ }
126
+
127
+ export function readTextFileIfPresent(filePath) {
128
+ if (!filePath) return null;
129
+ try {
130
+ return fs.readFileSync(filePath, "utf8").trim() || null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
@@ -11,6 +11,8 @@ import {
11
11
  } from "./args.mjs";
12
12
  import * as runner from "../runner/index.mjs";
13
13
  import { createRunReporter } from "./presentation/run-reporter.mjs";
14
+ import { createTreeReporter } from "./presentation/tree-reporter.mjs";
15
+ import { createRunEventsReporter } from "./presentation/events-reporter.mjs";
14
16
 
15
17
  export const sharedFlags = {
16
18
  dir: Flags.string({
@@ -64,7 +66,7 @@ export const runFlags = {
64
66
  }),
65
67
  "output-mode": Flags.string({
66
68
  description: "Reporter mode",
67
- options: ["compact", "debug"],
69
+ options: ["compact", "debug", "events"],
68
70
  }),
69
71
  debug: Flags.boolean({
70
72
  description: "Alias for --output-mode debug",
@@ -94,31 +96,57 @@ export async function executeRunCommand(command, flags, positionalType = null) {
94
96
  : flags.debug
95
97
  ? "debug"
96
98
  : flags["output-mode"] || "compact";
97
- const reporter = createRunReporter({ outputMode });
98
- const result = await runner.runAll(
99
- configs,
100
- typeValues,
101
- suiteSelectors,
102
- {
103
- ...flags,
99
+
100
+ let reporter;
101
+ let finalize = Promise.resolve();
102
+ let close = () => {};
103
+
104
+ if (outputMode === "compact" && process.stdout.isTTY) {
105
+ const tree = createTreeReporter({
106
+ stdout: process.stdout,
107
+ stderr: process.stderr,
108
+ productDir,
109
+ });
110
+ reporter = tree.reporter;
111
+ finalize = tree.finalize;
112
+ close = tree.close;
113
+ } else if (outputMode === "events") {
114
+ reporter = createRunEventsReporter({ stdout: process.stdout, stderr: process.stderr });
115
+ } else {
116
+ reporter = createRunReporter({ outputMode });
117
+ }
118
+
119
+ try {
120
+ const result = await runner.runAll(
121
+ configs,
104
122
  typeValues,
105
- fileNames,
106
- workers,
107
- fileTimeoutSeconds,
108
- shard,
109
- scenarioSeed: flags.seed || null,
110
- serviceFilter: flags.service || null,
111
- reporter,
112
- writeStatus: flags["write-status"],
113
- allowPartialStatus: flags["allow-partial-status"],
114
- ignoreSkipRules: flags["ignore-skip-rules"],
115
- },
116
- allConfigs
117
- );
118
- return {
119
- outputMode,
120
- ...result,
121
- };
123
+ suiteSelectors,
124
+ {
125
+ ...flags,
126
+ typeValues,
127
+ fileNames,
128
+ workers,
129
+ fileTimeoutSeconds,
130
+ shard,
131
+ scenarioSeed: flags.seed || null,
132
+ serviceFilter: flags.service || null,
133
+ reporter,
134
+ writeStatus: flags["write-status"],
135
+ allowPartialStatus: flags["allow-partial-status"],
136
+ ignoreSkipRules: flags["ignore-skip-rules"],
137
+ },
138
+ allConfigs
139
+ );
140
+ await finalize;
141
+ return {
142
+ outputMode,
143
+ ...result,
144
+ };
145
+ } catch (error) {
146
+ close();
147
+ await finalize.catch(() => {});
148
+ throw error;
149
+ }
122
150
  }
123
151
 
124
152
  export async function runStatusLike(commandName, flags) {
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const loadManagedConfigsMock = vi.fn();
4
+ const runAllMock = vi.fn();
5
+ const createRunReporterMock = vi.fn();
6
+ const createTreeReporterMock = vi.fn();
7
+ const createRunEventsReporterMock = vi.fn();
8
+
9
+ vi.mock("../app/configs.mjs", () => ({
10
+ loadManagedConfigs: loadManagedConfigsMock,
11
+ }));
12
+
13
+ vi.mock("../runner/index.mjs", () => ({
14
+ runAll: runAllMock,
15
+ }));
16
+
17
+ vi.mock("./presentation/run-reporter.mjs", () => ({
18
+ createRunReporter: createRunReporterMock,
19
+ }));
20
+
21
+ vi.mock("./presentation/tree-reporter.mjs", () => ({
22
+ createTreeReporter: createTreeReporterMock,
23
+ }));
24
+
25
+ vi.mock("./presentation/events-reporter.mjs", () => ({
26
+ createRunEventsReporter: createRunEventsReporterMock,
27
+ }));
28
+
29
+ const originalIsTTY = process.stdout.isTTY;
30
+ const originalStdoutGetWindowSize = process.stdout.getWindowSize;
31
+ const originalStderrGetWindowSize = process.stderr.getWindowSize;
32
+
33
+ beforeEach(() => {
34
+ loadManagedConfigsMock.mockResolvedValue({
35
+ allConfigs: [{ name: "api", productDir: "/tmp/product" }],
36
+ configs: [{ name: "api", productDir: "/tmp/product" }],
37
+ });
38
+ runAllMock.mockResolvedValue({ ok: true });
39
+ createRunReporterMock.mockReturnValue({ outputMode: "compact" });
40
+ createRunEventsReporterMock.mockReturnValue({ outputMode: "events" });
41
+ createTreeReporterMock.mockReturnValue({
42
+ reporter: { outputMode: "compact" },
43
+ finalize: Promise.resolve(),
44
+ close: vi.fn(),
45
+ });
46
+ Object.defineProperty(process.stdout, "isTTY", {
47
+ configurable: true,
48
+ value: true,
49
+ });
50
+ process.stdout.getWindowSize = vi.fn(() => [100, 40]);
51
+ process.stderr.getWindowSize = vi.fn(() => [100, 40]);
52
+ });
53
+
54
+ afterEach(() => {
55
+ loadManagedConfigsMock.mockReset();
56
+ runAllMock.mockReset();
57
+ createRunReporterMock.mockReset();
58
+ createTreeReporterMock.mockReset();
59
+ createRunEventsReporterMock.mockReset();
60
+ Object.defineProperty(process.stdout, "isTTY", {
61
+ configurable: true,
62
+ value: originalIsTTY,
63
+ });
64
+ process.stdout.getWindowSize = originalStdoutGetWindowSize;
65
+ process.stderr.getWindowSize = originalStderrGetWindowSize;
66
+ });
67
+
68
+ describe("executeRunCommand", () => {
69
+ it("uses the tree reporter for compact TTY runs and awaits finalization", async () => {
70
+ let finalizeResolved = false;
71
+ createTreeReporterMock.mockReturnValueOnce({
72
+ reporter: { outputMode: "compact" },
73
+ finalize: Promise.resolve().then(() => {
74
+ finalizeResolved = true;
75
+ }),
76
+ close: vi.fn(),
77
+ });
78
+
79
+ const { executeRunCommand } = await import("./command-helpers.mjs");
80
+ const result = await executeRunCommand({ jsonEnabled: () => false }, {}, null);
81
+
82
+ expect(createTreeReporterMock).toHaveBeenCalledWith(
83
+ expect.objectContaining({ productDir: "/tmp/product" })
84
+ );
85
+ expect(runAllMock).toHaveBeenCalledWith(
86
+ expect.any(Array),
87
+ expect.any(Array),
88
+ expect.any(Array),
89
+ expect.objectContaining({ reporter: { outputMode: "compact" } }),
90
+ expect.any(Array)
91
+ );
92
+ expect(finalizeResolved).toBe(true);
93
+ expect(result.outputMode).toBe("compact");
94
+ });
95
+
96
+ it("uses the events reporter when requested", async () => {
97
+ const { executeRunCommand } = await import("./command-helpers.mjs");
98
+ await executeRunCommand(
99
+ { jsonEnabled: () => false },
100
+ { "output-mode": "events" },
101
+ null
102
+ );
103
+
104
+ expect(createRunEventsReporterMock).toHaveBeenCalled();
105
+ expect(createRunReporterMock).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it("closes the tree reporter when runner.runAll throws", async () => {
109
+ const close = vi.fn();
110
+ createTreeReporterMock.mockReturnValueOnce({
111
+ reporter: { outputMode: "compact" },
112
+ finalize: Promise.resolve(),
113
+ close,
114
+ });
115
+ runAllMock.mockRejectedValueOnce(new Error("boom"));
116
+
117
+ const { executeRunCommand } = await import("./command-helpers.mjs");
118
+
119
+ await expect(executeRunCommand({ jsonEnabled: () => false }, {}, null)).rejects.toThrow("boom");
120
+ expect(close).toHaveBeenCalled();
121
+ });
122
+ });
@@ -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
+ });