@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.
- package/README.md +37 -7
- 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 +15 -10
- package/lib/config-api/index.test.mjs +54 -0
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +138 -5
- package/lib/runtime/index.mjs +68 -2
- 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 +5 -1
- package/lib/runtime-src/k6/http.js +213 -23
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- 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/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("
|
|
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(
|
|
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
|
-
|
|
456
|
+
First-class runtime namespaces:
|
|
453
457
|
|
|
454
458
|
```ts
|
|
455
|
-
import {
|
|
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) =>
|
|
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
|
+
});
|