@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
package/README.md CHANGED
@@ -365,9 +365,11 @@ HTTP suites:
365
365
 
366
366
  ```ts
367
367
  import { defineHttpSuite } from "@elench/testkit";
368
+ import { expect } from "@elench/testkit/runtime";
368
369
 
369
370
  const suite = defineHttpSuite(({ rawReq }) => {
370
- rawReq("GET", "/health");
371
+ const response = rawReq.get("/health");
372
+ expect.status(response, 200, "health returns 200");
371
373
  });
372
374
 
373
375
  export default suite;
@@ -410,9 +412,11 @@ export default defineConfig({
410
412
  },
411
413
  });
412
414
 
413
- const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, req }) => {
415
+ const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, actors, req }) => {
414
416
  req.get("/api/auth/session");
415
417
  actor?.req.get("/api/auth/session");
418
+ req.as("outsider").get("/api/auth/session");
419
+ actors.get("reviewer").rawReq.get("/api/auth/session");
416
420
  });
417
421
  ```
418
422
 
@@ -439,7 +443,7 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
439
443
  includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
440
444
  });
441
445
 
442
- const selected = scenario.resource("selected-endpoint", () => rawReq("GET", plan.endpoint));
446
+ const selected = scenario.resource("selected-endpoint", () => rawReq.get(plan.endpoint));
443
447
 
444
448
  scenario.step("fetch selected endpoint", () => {
445
449
  selected.get();
@@ -449,21 +453,47 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
449
453
  export default suite;
450
454
  ```
451
455
 
452
- Low-level runtime primitives remain available:
456
+ First-class runtime namespaces:
453
457
 
454
458
  ```ts
455
- import { check, group, http, waitFor } from "@elench/testkit/runtime";
459
+ import { checks, expect, parse, waitFor } from "@elench/testkit/runtime";
460
+
461
+ const suite = defineHttpSuite(({ actor, rawReq, req }) => {
462
+ const me = req.get("/api/v1/me");
463
+ expect.status(me, 200, "authenticated session loads");
464
+ expect.json(me, (body) => typeof body.data?.id === "string", "response includes user id");
465
+
466
+ checks.authGate(rawReq, "sessions", {
467
+ get: ["/api/v1/sessions"],
468
+ });
469
+
470
+ actor?.rawHeaders();
471
+
472
+ const upload = req.multipart.post("/api/v1/uploads", {
473
+ fields: { name: "fixture" },
474
+ files: [{ field: "file", data: "hello", filename: "fixture.txt", contentType: "text/plain" }],
475
+ });
476
+
477
+ expect.status(upload, 201, "upload is accepted");
478
+ parse.safeJson(upload);
479
+ });
480
+ ```
481
+
482
+ Low-level runtime primitives still exist when you genuinely need them:
483
+
484
+ ```ts
485
+ import { check, group, http } from "@elench/testkit/runtime";
456
486
  ```
457
487
 
458
488
  `waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
459
489
  Consumers should not set local timeout values in test files.
460
490
 
461
491
  ```ts
462
- import { waitFor } from "@elench/testkit/runtime";
492
+ import { parse, waitFor } from "@elench/testkit/runtime";
463
493
 
464
494
  const response = waitFor(
465
495
  () => req.get("/api/v1/jobs/123"),
466
- (res) => JSON.parse(res.body).data?.status === "completed",
496
+ (res) => parse.json(res).data?.status === "completed",
467
497
  { description: "job 123 to complete" }
468
498
  );
469
499
  ```
@@ -0,0 +1,64 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { spawn } from "child_process";
4
+ import { startClaudeHostedSession } from "./providers/claude.mjs";
5
+ import { startCodexHostedSession } from "./providers/codex.mjs";
6
+
7
+ const PROVIDERS = ["codex", "claude"];
8
+
9
+ export function resolvePreferredProvider(preferred = null, env = process.env) {
10
+ if (preferred && preferred !== "auto") {
11
+ if (!isProviderInstalled(preferred, env)) {
12
+ throw new Error(`${preferred} is not installed or not available on PATH`);
13
+ }
14
+ return preferred;
15
+ }
16
+
17
+ for (const provider of PROVIDERS) {
18
+ if (isProviderInstalled(provider, env)) return provider;
19
+ }
20
+ throw new Error("Neither codex nor claude was found on PATH");
21
+ }
22
+
23
+ export function isProviderInstalled(provider, env = process.env) {
24
+ const pathValue = env.PATH || "";
25
+ const candidates = pathValue.split(path.delimiter).filter(Boolean);
26
+ const fileNames =
27
+ process.platform === "win32"
28
+ ? [`${provider}.exe`, `${provider}.cmd`, `${provider}.bat`, provider]
29
+ : [provider];
30
+ for (const directory of candidates) {
31
+ for (const fileName of fileNames) {
32
+ const candidate = path.join(directory, fileName);
33
+ try {
34
+ fs.accessSync(candidate, fs.constants.X_OK);
35
+ return true;
36
+ } catch {
37
+ // Continue searching PATH entries.
38
+ }
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
44
+ export function startAgentSession({ provider = "auto", cwd, prompt, onEvent, purpose = "investigate" } = {}) {
45
+ const resolvedProvider = resolvePreferredProvider(provider);
46
+ if (resolvedProvider === "claude") {
47
+ return startClaudeHostedSession({ cwd, prompt, onEvent, purpose });
48
+ }
49
+ return startCodexHostedSession({ cwd, prompt, onEvent, purpose });
50
+ }
51
+
52
+ export function startInteractiveAgentHandoff({ provider = "auto", cwd, prompt } = {}) {
53
+ const resolvedProvider = resolvePreferredProvider(provider);
54
+ const command = resolvedProvider;
55
+ const args = prompt ? [prompt] : [];
56
+ const child = spawn(command, args, {
57
+ cwd,
58
+ stdio: "inherit",
59
+ });
60
+ return new Promise((resolve, reject) => {
61
+ child.on("error", reject);
62
+ child.on("close", (code) => resolve({ provider: resolvedProvider, exitCode: code ?? 0 }));
63
+ });
64
+ }
@@ -0,0 +1,75 @@
1
+ import { loadInvestigationContext } from "./investigation-context.mjs";
2
+ import { buildInvestigationPrompt } from "./prompt-builder.mjs";
3
+ import { startAgentSession, startInteractiveAgentHandoff } from "./index.mjs";
4
+
5
+ export function createInvestigationRequest({
6
+ productDir,
7
+ serviceName,
8
+ filePath,
9
+ provider = "auto",
10
+ userMessage,
11
+ } = {}) {
12
+ const context = loadInvestigationContext({
13
+ productDir,
14
+ serviceName,
15
+ filePath,
16
+ });
17
+ const prompt = buildInvestigationPrompt({ context, userMessage });
18
+ return {
19
+ provider,
20
+ context,
21
+ prompt,
22
+ };
23
+ }
24
+
25
+ export function startHostedInvestigation({
26
+ productDir,
27
+ serviceName,
28
+ filePath,
29
+ provider = "auto",
30
+ userMessage,
31
+ onEvent,
32
+ } = {}) {
33
+ const request = createInvestigationRequest({
34
+ productDir,
35
+ serviceName,
36
+ filePath,
37
+ provider,
38
+ userMessage,
39
+ });
40
+ const session = startAgentSession({
41
+ provider: request.provider,
42
+ cwd: productDir,
43
+ prompt: request.prompt,
44
+ onEvent,
45
+ });
46
+ return {
47
+ ...session,
48
+ request,
49
+ };
50
+ }
51
+
52
+ export async function runInteractiveInvestigation({
53
+ productDir,
54
+ serviceName,
55
+ filePath,
56
+ provider = "auto",
57
+ userMessage,
58
+ } = {}) {
59
+ const request = createInvestigationRequest({
60
+ productDir,
61
+ serviceName,
62
+ filePath,
63
+ provider,
64
+ userMessage,
65
+ });
66
+ const result = await startInteractiveAgentHandoff({
67
+ provider: request.provider,
68
+ cwd: productDir,
69
+ prompt: request.prompt,
70
+ });
71
+ return {
72
+ ...result,
73
+ request,
74
+ };
75
+ }
@@ -0,0 +1,102 @@
1
+ import path from "path";
2
+ import {
3
+ collectArtifactEntries,
4
+ formatArtifactPreview,
5
+ formatFileDetail,
6
+ getServiceLogRefs,
7
+ getSetupOperationsForService,
8
+ loadCurrentRunArtifact,
9
+ resolveFileSubject,
10
+ } from "../viewer.mjs";
11
+ import { readLogTail } from "../../runner/logs.mjs";
12
+
13
+ export function loadInvestigationContext({
14
+ productDir,
15
+ serviceName,
16
+ filePath,
17
+ logTail = 20,
18
+ failureLimit = 5,
19
+ previewLength = 6,
20
+ } = {}) {
21
+ if (!productDir) throw new Error("productDir is required");
22
+ if (!serviceName) throw new Error("serviceName is required");
23
+ if (!filePath) throw new Error("filePath is required");
24
+
25
+ const runArtifact = loadCurrentRunArtifact(productDir);
26
+ const subject = resolveFileSubject(runArtifact, filePath, serviceName);
27
+ const detailLines = formatFileDetail(productDir, runArtifact, subject, {
28
+ logTail,
29
+ failureLimit,
30
+ previewLength,
31
+ });
32
+ const artifacts = collectArtifactEntries(productDir, runArtifact, subject.file.path, subject.service.name).map((entry) => ({
33
+ name: entry.artifactRef.name,
34
+ kind: entry.artifactRef.kind || null,
35
+ summary: entry.artifactRef.summary || null,
36
+ path: entry.artifactRef.path,
37
+ previewLines: formatArtifactPreview(entry.payload, previewLength),
38
+ }));
39
+ const setupOperations = getSetupOperationsForService(runArtifact, subject.service.name).slice(0, 8).map((operation) => ({
40
+ stage: operation.stage,
41
+ status: operation.status,
42
+ summary: operation.summary || null,
43
+ durationMs: operation.durationMs ?? null,
44
+ logPath: operation.logRef?.path || null,
45
+ error: operation.error || null,
46
+ }));
47
+ const backendLogs = getServiceLogRefs(runArtifact, subject.service.name).map((logRef) => ({
48
+ runtimeLabel: logRef.runtimeLabel,
49
+ path: logRef.path,
50
+ lines: readLogTail(path.join(productDir, logRef.path), logTail),
51
+ }));
52
+
53
+ return {
54
+ productDir,
55
+ runArtifact,
56
+ subject,
57
+ detailLines,
58
+ summary: {
59
+ service: subject.service.name,
60
+ suite: `${subject.suite.type}:${subject.suite.name}`,
61
+ file: subject.file.path,
62
+ status: subject.file.status,
63
+ durationMs: subject.file.durationMs || 0,
64
+ error: subject.file.error || null,
65
+ failureCount: Array.isArray(subject.file.failureDetails) ? subject.file.failureDetails.length : 0,
66
+ },
67
+ setupOperations,
68
+ artifacts,
69
+ backendLogs,
70
+ };
71
+ }
72
+
73
+ export function formatInvestigationTranscriptContext(context) {
74
+ if (!context) return "";
75
+ const artifactSummary = context.artifacts.length === 0
76
+ ? "none"
77
+ : context.artifacts
78
+ .map((entry) => `${entry.name}${entry.kind ? ` [${entry.kind}]` : ""}: ${entry.path}`)
79
+ .join("\n");
80
+ const logSummary = context.backendLogs.length === 0
81
+ ? "none"
82
+ : context.backendLogs.map((entry) => `${entry.runtimeLabel}: ${entry.path}`).join("\n");
83
+
84
+ return [
85
+ `Service: ${context.summary.service}`,
86
+ `Suite: ${context.summary.suite}`,
87
+ `File: ${context.summary.file}`,
88
+ `Status: ${context.summary.status}`,
89
+ context.summary.error ? `Error: ${context.summary.error}` : null,
90
+ "",
91
+ "Artifacts:",
92
+ artifactSummary,
93
+ "",
94
+ "Backend Logs:",
95
+ logSummary,
96
+ "",
97
+ "Detailed Failure View:",
98
+ ...context.detailLines,
99
+ ]
100
+ .filter(Boolean)
101
+ .join("\n");
102
+ }
@@ -0,0 +1,144 @@
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 {
6
+ formatInvestigationTranscriptContext,
7
+ loadInvestigationContext,
8
+ } from "./investigation-context.mjs";
9
+ import { buildInvestigationPrompt, defaultInvestigationMessage } from "./prompt-builder.mjs";
10
+
11
+ const tempDirs = [];
12
+
13
+ afterEach(() => {
14
+ while (tempDirs.length > 0) {
15
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ function makeTempProduct() {
20
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-investigation-"));
21
+ tempDirs.push(productDir);
22
+ fs.mkdirSync(path.join(productDir, ".testkit", "results"), { recursive: true });
23
+ return productDir;
24
+ }
25
+
26
+ function writeJson(filePath, value) {
27
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
28
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
29
+ }
30
+
31
+ describe("investigation context", () => {
32
+ it("loads the selected failure, artifacts, logs, and formatted detail lines", () => {
33
+ const productDir = makeTempProduct();
34
+ writeJson(path.join(productDir, "artifacts", "failure.json"), {
35
+ source: "testkit-runtime-artifact",
36
+ data: { message: "artifact payload" },
37
+ });
38
+ fs.mkdirSync(path.join(productDir, "logs"), { recursive: true });
39
+ fs.writeFileSync(path.join(productDir, "logs", "api.log"), "first line\nsecond line\n");
40
+
41
+ writeJson(path.join(productDir, ".testkit", "results", "latest.json"), {
42
+ services: [
43
+ {
44
+ name: "api",
45
+ suites: [
46
+ {
47
+ name: "users",
48
+ type: "integration",
49
+ files: [
50
+ {
51
+ path: "tests/api/users.int.testkit.ts",
52
+ status: "failed",
53
+ durationMs: 1000,
54
+ error: "status 500",
55
+ failureDetails: [
56
+ {
57
+ kind: "http-assertion",
58
+ title: "status is 200",
59
+ message: "GET /users expected 200, got 500",
60
+ response: { status: 500, bodyPreview: "{\"error\":\"boom\"}" },
61
+ },
62
+ ],
63
+ artifacts: [
64
+ {
65
+ name: "failure-artifact",
66
+ kind: "example",
67
+ summary: "captured payload",
68
+ path: "artifacts/failure.json",
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ logs: {
78
+ services: [
79
+ {
80
+ serviceName: "api",
81
+ runtimeLabel: "api",
82
+ path: "logs/api.log",
83
+ },
84
+ ],
85
+ },
86
+ setup: {
87
+ operations: [
88
+ {
89
+ serviceName: "api",
90
+ stage: "runtime:prepare",
91
+ status: "passed",
92
+ summary: "prepared runtime",
93
+ durationMs: 1200,
94
+ logRef: { path: "logs/api.log" },
95
+ },
96
+ ],
97
+ },
98
+ });
99
+
100
+ const context = loadInvestigationContext({
101
+ productDir,
102
+ serviceName: "api",
103
+ filePath: "tests/api/users.int.testkit.ts",
104
+ });
105
+
106
+ expect(context.summary).toMatchObject({
107
+ service: "api",
108
+ suite: "integration:users",
109
+ file: "tests/api/users.int.testkit.ts",
110
+ status: "failed",
111
+ error: "status 500",
112
+ });
113
+ expect(context.artifacts[0]).toMatchObject({
114
+ name: "failure-artifact",
115
+ kind: "example",
116
+ path: "artifacts/failure.json",
117
+ });
118
+ expect(context.backendLogs[0].lines).toContain("second line");
119
+ expect(context.detailLines.join("\n")).toContain("GET /users expected 200, got 500");
120
+ expect(formatInvestigationTranscriptContext(context)).toContain("Artifacts:");
121
+ });
122
+
123
+ it("builds the default investigation prompt with contextual evidence", () => {
124
+ const context = {
125
+ summary: {
126
+ service: "api",
127
+ suite: "integration:users",
128
+ file: "tests/api/users.int.testkit.ts",
129
+ status: "failed",
130
+ error: "status 500",
131
+ },
132
+ artifacts: [],
133
+ backendLogs: [],
134
+ detailLines: ["File: tests/api/users.int.testkit.ts", "Error: status 500"],
135
+ };
136
+
137
+ const prompt = buildInvestigationPrompt({ context });
138
+
139
+ expect(defaultInvestigationMessage()).toContain("Investigate");
140
+ expect(prompt).toContain("User request:");
141
+ expect(prompt).toContain("Service: api");
142
+ expect(prompt).toContain("Detailed Failure View:");
143
+ });
144
+ });
@@ -0,0 +1,25 @@
1
+ import { formatInvestigationTranscriptContext } from "./investigation-context.mjs";
2
+
3
+ const DEFAULT_INVESTIGATION_MESSAGE =
4
+ "Investigate the selected testkit failure. Explain the most likely root cause, cite concrete evidence from the provided context, and suggest the next debugging steps or code changes.";
5
+
6
+ export function defaultInvestigationMessage() {
7
+ return DEFAULT_INVESTIGATION_MESSAGE;
8
+ }
9
+
10
+ export function buildInvestigationPrompt({ context, userMessage } = {}) {
11
+ const message = userMessage || DEFAULT_INVESTIGATION_MESSAGE;
12
+ const transcriptContext = formatInvestigationTranscriptContext(context);
13
+
14
+ return [
15
+ "You are investigating a failed testkit run.",
16
+ "Use the provided run context as the primary evidence.",
17
+ "If the evidence is incomplete, say what additional command or file you would inspect next.",
18
+ "Keep the response actionable and focused on root cause, evidence, and next steps.",
19
+ "",
20
+ `User request: ${message}`,
21
+ "",
22
+ "Run context:",
23
+ transcriptContext,
24
+ ].join("\n");
25
+ }
@@ -0,0 +1,74 @@
1
+ import { execa } from "execa";
2
+ import {
3
+ buildErrorEvent,
4
+ buildStatusEvent,
5
+ buildToolEvent,
6
+ createHostedSessionRunner,
7
+ extractTextFragments,
8
+ } from "./shared.mjs";
9
+
10
+ export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
11
+ const args = [
12
+ "-p",
13
+ "--output-format",
14
+ "stream-json",
15
+ "--include-partial-messages",
16
+ ];
17
+
18
+ if (purpose === "investigate") {
19
+ args.push("--permission-mode", "plan");
20
+ }
21
+
22
+ args.push(prompt);
23
+
24
+ const child = execa("claude", args, {
25
+ cwd,
26
+ stdout: "pipe",
27
+ stderr: "pipe",
28
+ reject: false,
29
+ });
30
+
31
+ return createHostedSessionRunner({
32
+ provider: "claude",
33
+ child,
34
+ onEvent,
35
+ parsePayload: parseClaudePayload,
36
+ readFinalText() {
37
+ return null;
38
+ },
39
+ });
40
+ }
41
+
42
+ function parseClaudePayload(payload) {
43
+ const events = [];
44
+ if (!payload || typeof payload !== "object") return events;
45
+
46
+ const type = payload.type || payload.event || payload.kind || null;
47
+ const errorMessage = payload.error?.message || payload.error || null;
48
+ if (errorMessage) {
49
+ const event = buildErrorEvent(errorMessage);
50
+ if (event) events.push(event);
51
+ return events;
52
+ }
53
+
54
+ if (type && /tool/i.test(type)) {
55
+ const event = buildToolEvent(
56
+ payload.name || payload.tool_name || payload.tool || type,
57
+ payload.detail || payload.summary || null
58
+ );
59
+ if (event) events.push(event);
60
+ return events;
61
+ }
62
+
63
+ const fragments = [...new Set(extractTextFragments(payload, []))];
64
+ if (fragments.length > 0) {
65
+ for (const fragment of fragments) {
66
+ events.push({ type: "delta", text: fragment });
67
+ }
68
+ return events;
69
+ }
70
+
71
+ const statusEvent = buildStatusEvent(type ? `Claude event: ${type}` : JSON.stringify(payload));
72
+ if (statusEvent) events.push(statusEvent);
73
+ return events;
74
+ }
@@ -0,0 +1,95 @@
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("Claude hosted session", () => {
43
+ it("normalizes streamed tool, delta, status, and final events", async () => {
44
+ const { startClaudeHostedSession } = await import("./claude.mjs");
45
+ const handle = createChildHandle();
46
+ execaMock.mockReturnValue(handle.child);
47
+ const events = [];
48
+
49
+ const session = startClaudeHostedSession({
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: "tool_use", name: "Bash", detail: "rg failure" })}\n`);
58
+ handle.child.stdout.write(`${JSON.stringify({ type: "message_delta", delta: "Likely root cause." })}\n`);
59
+ handle.child.stderr.write("stderr note\n");
60
+ handle.finish({ exitCode: 0 });
61
+
62
+ const result = await session.completion;
63
+
64
+ expect(execaMock).toHaveBeenCalledWith(
65
+ "claude",
66
+ expect.arrayContaining(["-p", "--output-format", "stream-json", "--include-partial-messages"]),
67
+ expect.objectContaining({ cwd: "/tmp/project", reject: false })
68
+ );
69
+ expect(events[0]).toMatchObject({ provider: "claude", type: "start" });
70
+ expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "tool", name: "Bash" }));
71
+ expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "delta", text: "Likely root cause." }));
72
+ expect(events).toContainEqual(expect.objectContaining({ provider: "claude", type: "status", message: "stderr note" }));
73
+ expect(events.at(-2)).toMatchObject({ provider: "claude", type: "final", text: "Likely root cause." });
74
+ expect(events.at(-1)).toMatchObject({ provider: "claude", type: "exit", code: 0 });
75
+ expect(result.finalText).toBe("Likely root cause.");
76
+ });
77
+
78
+ it("cancels the child process", async () => {
79
+ const { startClaudeHostedSession } = await import("./claude.mjs");
80
+ const handle = createChildHandle();
81
+ execaMock.mockReturnValue(handle.child);
82
+
83
+ const session = startClaudeHostedSession({
84
+ cwd: "/tmp/project",
85
+ prompt: "Investigate this failure",
86
+ });
87
+
88
+ session.cancel();
89
+ handle.finish({ exitCode: 130 });
90
+ const result = await session.completion;
91
+
92
+ expect(handle.child.kill).toHaveBeenCalledWith("SIGTERM");
93
+ expect(result.cancelled).toBe(true);
94
+ });
95
+ });