@elench/testkit 0.1.81 → 0.1.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/lib/cli/agents/index.mjs +64 -0
- package/lib/cli/agents/investigate.mjs +75 -0
- package/lib/cli/agents/investigation-context.mjs +102 -0
- package/lib/cli/agents/investigation-context.test.mjs +144 -0
- package/lib/cli/agents/prompt-builder.mjs +25 -0
- package/lib/cli/agents/providers/claude.mjs +74 -0
- package/lib/cli/agents/providers/claude.test.mjs +95 -0
- package/lib/cli/agents/providers/codex.mjs +83 -0
- package/lib/cli/agents/providers/codex.test.mjs +93 -0
- package/lib/cli/agents/providers/shared.mjs +134 -0
- package/lib/cli/command-helpers.mjs +53 -25
- package/lib/cli/command-helpers.test.mjs +122 -0
- package/lib/cli/commands/investigate.mjs +87 -0
- package/lib/cli/commands/investigate.test.mjs +83 -0
- package/lib/cli/entrypoint.mjs +3 -0
- package/lib/cli/presentation/colors.mjs +12 -0
- package/lib/cli/presentation/events-reporter.mjs +135 -0
- package/lib/cli/presentation/events-reporter.test.mjs +73 -0
- package/lib/cli/presentation/summary-box.mjs +11 -11
- package/lib/cli/presentation/summary-box.test.mjs +17 -0
- package/lib/cli/presentation/tree-reporter.mjs +159 -0
- package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
- package/lib/cli/tui/run-app.mjs +1 -0
- package/lib/cli/tui/run-session-app.mjs +370 -0
- package/lib/cli/tui/run-session-app.test.mjs +50 -0
- package/lib/cli/tui/run-session-state.mjs +481 -0
- package/lib/cli/tui/run-tree-state.mjs +1 -0
- package/lib/cli/tui/run-tree-state.test.mjs +324 -0
- package/lib/config-api/auth-fixtures.mjs +767 -0
- package/lib/config-api/index.d.ts +92 -108
- package/lib/config-api/index.mjs +22 -12
- package/lib/config-api/index.test.mjs +103 -210
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +34 -10
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +177 -27
- package/lib/runtime/index.mjs +68 -3
- package/lib/runtime-src/k6/http-assertions.js +31 -1
- package/lib/runtime-src/k6/http-checks.js +120 -0
- package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
- package/lib/runtime-src/k6/http-suite-runtime.js +151 -0
- package/lib/runtime-src/k6/http.js +285 -56
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- package/lib/runtime-src/k6/scenario-suite.js +13 -110
- package/lib/runtime-src/k6/suite.js +13 -107
- package/lib/runtime-src/shared/error-body.mjs +42 -0
- package/lib/runtime-src/shared/http-parsing.mjs +68 -0
- package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/lib/config-api/profiles.mjs +0 -640
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;
|
|
@@ -380,32 +382,41 @@ Named HTTP profiles live in `testkit.config.ts` and can be referenced by name:
|
|
|
380
382
|
|
|
381
383
|
```ts
|
|
382
384
|
import { defineHttpSuite } from "@elench/testkit";
|
|
383
|
-
import {
|
|
385
|
+
import { auth, defineConfig } from "@elench/testkit/config";
|
|
386
|
+
|
|
387
|
+
const appAuth = auth.fixture({
|
|
388
|
+
contract: auth.contracts.jsonSession({
|
|
389
|
+
authCookie: "session",
|
|
390
|
+
organizationIdPath: "data.organizations[0].id",
|
|
391
|
+
}),
|
|
392
|
+
topology: auth.topologies.crossOrg({
|
|
393
|
+
namespace: "example-app",
|
|
394
|
+
actors: {
|
|
395
|
+
primary: { org: "primary" },
|
|
396
|
+
reviewer: { org: "primary" },
|
|
397
|
+
outsider: { org: "secondary" },
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
384
401
|
|
|
385
402
|
export default defineConfig({
|
|
386
403
|
profiles: {
|
|
387
|
-
http: {
|
|
388
|
-
defaultAuth:
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
session: {
|
|
396
|
-
authCookie: "session",
|
|
397
|
-
},
|
|
398
|
-
headers: {
|
|
399
|
-
contentTypeJson: true,
|
|
400
|
-
forwardedFor: "deterministic",
|
|
401
|
-
},
|
|
402
|
-
}).session(),
|
|
403
|
-
},
|
|
404
|
+
http: appAuth.profiles({
|
|
405
|
+
defaultAuth: auth.profile.actor("primary"),
|
|
406
|
+
reviewers: auth.profile.actors({
|
|
407
|
+
actors: ["reviewer", "outsider"],
|
|
408
|
+
primaryActor: "reviewer",
|
|
409
|
+
}),
|
|
410
|
+
raw: auth.profile.raw(),
|
|
411
|
+
}),
|
|
404
412
|
},
|
|
405
413
|
});
|
|
406
414
|
|
|
407
|
-
const suite = defineHttpSuite({ profile: "defaultAuth" }, ({
|
|
408
|
-
req("
|
|
415
|
+
const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, actors, req }) => {
|
|
416
|
+
req.get("/api/auth/session");
|
|
417
|
+
actor?.req.get("/api/auth/session");
|
|
418
|
+
req.as("outsider").get("/api/auth/session");
|
|
419
|
+
actors.get("reviewer").rawReq.get("/api/auth/session");
|
|
409
420
|
});
|
|
410
421
|
```
|
|
411
422
|
|
|
@@ -432,7 +443,7 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
|
432
443
|
includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
|
|
433
444
|
});
|
|
434
445
|
|
|
435
|
-
const selected = scenario.resource("selected-endpoint", () => rawReq(
|
|
446
|
+
const selected = scenario.resource("selected-endpoint", () => rawReq.get(plan.endpoint));
|
|
436
447
|
|
|
437
448
|
scenario.step("fetch selected endpoint", () => {
|
|
438
449
|
selected.get();
|
|
@@ -442,21 +453,47 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
|
442
453
|
export default suite;
|
|
443
454
|
```
|
|
444
455
|
|
|
445
|
-
|
|
456
|
+
First-class runtime namespaces:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
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:
|
|
446
483
|
|
|
447
484
|
```ts
|
|
448
|
-
import { check, group, http
|
|
485
|
+
import { check, group, http } from "@elench/testkit/runtime";
|
|
449
486
|
```
|
|
450
487
|
|
|
451
488
|
`waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
|
|
452
489
|
Consumers should not set local timeout values in test files.
|
|
453
490
|
|
|
454
491
|
```ts
|
|
455
|
-
import { waitFor } from "@elench/testkit/runtime";
|
|
492
|
+
import { parse, waitFor } from "@elench/testkit/runtime";
|
|
456
493
|
|
|
457
494
|
const response = waitFor(
|
|
458
|
-
() => req("
|
|
459
|
-
(res) =>
|
|
495
|
+
() => req.get("/api/v1/jobs/123"),
|
|
496
|
+
(res) => parse.json(res).data?.status === "completed",
|
|
460
497
|
{ description: "job 123 to complete" }
|
|
461
498
|
);
|
|
462
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
|
+
}
|