@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,370 @@
1
+ import React, { createElement, useEffect, useState } from "react";
2
+ import { Box, Text, useAnimation, useInput } from "ink";
3
+ import figures from "figures";
4
+ import { formatDuration } from "../../runner/formatting.mjs";
5
+ import {
6
+ bold,
7
+ colorService,
8
+ colorTypeBadge,
9
+ dim,
10
+ green,
11
+ red,
12
+ yellow,
13
+ } from "../presentation/colors.mjs";
14
+ import { renderSummaryBox } from "../presentation/summary-box.mjs";
15
+ import { renderFailureBlock } from "../presentation/failure-presentation.mjs";
16
+ import { getTerminalWidth } from "../presentation/terminal-layout.mjs";
17
+ import { defaultInvestigationMessage } from "../agents/prompt-builder.mjs";
18
+ import { loadInvestigationContext } from "../agents/investigation-context.mjs";
19
+
20
+ const TREE_BRANCH = "\u251C\u2500\u2500 ";
21
+ const TREE_LAST = "\u2514\u2500\u2500 ";
22
+ const TREE_PIPE = "\u2502 ";
23
+ const TREE_SPACE = " ";
24
+ const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
25
+
26
+ export function RunSessionApp({
27
+ sessionState,
28
+ stdout,
29
+ productDir,
30
+ onInvestigate,
31
+ onRequestClose,
32
+ onCancelInvestigation,
33
+ } = {}) {
34
+ const [snapshot, setSnapshot] = useState(() => sessionState.getSnapshot());
35
+ const [detailLines, setDetailLines] = useState([]);
36
+ const { frame } = useAnimation({ interval: 80, isActive: !snapshot.finished });
37
+ const spinnerFrame = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
38
+
39
+ useEffect(() => {
40
+ const unsubscribe = sessionState.subscribe(() => {
41
+ setSnapshot(sessionState.getSnapshot());
42
+ });
43
+ return unsubscribe;
44
+ }, [sessionState]);
45
+
46
+ useEffect(() => {
47
+ if (!snapshot.selectedFailure) {
48
+ setDetailLines([]);
49
+ return;
50
+ }
51
+ try {
52
+ const context = loadInvestigationContext({
53
+ productDir,
54
+ serviceName: snapshot.selectedFailure.serviceName,
55
+ filePath: snapshot.selectedFailure.filePath,
56
+ });
57
+ setDetailLines(context.detailLines);
58
+ } catch (error) {
59
+ setDetailLines([error instanceof Error ? error.message : String(error)]);
60
+ }
61
+ }, [productDir, snapshot.selectedFailure?.key]);
62
+
63
+ useInput((input, key) => {
64
+ if (!snapshot.finished) {
65
+ if (input === "q") sessionState.setNotice("Run is still in progress. Wait for completion before closing.");
66
+ return;
67
+ }
68
+
69
+ if (input === "q") {
70
+ onRequestClose?.();
71
+ return;
72
+ }
73
+
74
+ if (snapshot.mode === "complete") {
75
+ if (key.downArrow || input === "j") {
76
+ sessionState.selectNextFailure();
77
+ return;
78
+ }
79
+ if (key.upArrow || input === "k") {
80
+ sessionState.selectPreviousFailure();
81
+ return;
82
+ }
83
+ if (key.return || input === "y" || input === "i") {
84
+ if (snapshot.selectedFailure) {
85
+ onInvestigate?.({
86
+ provider: "auto",
87
+ userMessage: defaultInvestigationMessage(),
88
+ });
89
+ }
90
+ return;
91
+ }
92
+ if (input === "c" && snapshot.selectedFailure) {
93
+ onInvestigate?.({ provider: "claude", userMessage: defaultInvestigationMessage() });
94
+ return;
95
+ }
96
+ if (input === "o" && snapshot.selectedFailure) {
97
+ onInvestigate?.({ provider: "codex", userMessage: defaultInvestigationMessage() });
98
+ }
99
+ return;
100
+ }
101
+
102
+ if (snapshot.mode === "investigating") {
103
+ if (input === "x") {
104
+ onCancelInvestigation?.();
105
+ return;
106
+ }
107
+ if (input === "b" && snapshot.agentSession?.status !== "running") {
108
+ sessionState.returnToSummary();
109
+ }
110
+ }
111
+ });
112
+
113
+ const treeElements = buildTreeElements({ snapshot, spinnerFrame, stdout });
114
+ const detailElements =
115
+ snapshot.mode === "investigating"
116
+ ? buildAgentPane(snapshot)
117
+ : buildFailureDetailPane(snapshot, detailLines);
118
+
119
+ const footerText = buildFooterText(snapshot);
120
+ const headerText = buildHeaderText(snapshot);
121
+ const summaryLines =
122
+ snapshot.finished && snapshot.summaryData
123
+ ? renderSummaryBox(snapshot.summaryData.rows, { stdout })
124
+ : [];
125
+ const terminalWidth = getTerminalWidth(stdout, 100);
126
+ const leftWidth = Math.max(42, Math.floor(terminalWidth * 0.58));
127
+ const rightWidth = Math.max(28, terminalWidth - leftWidth - 1);
128
+
129
+ return createElement(
130
+ Box,
131
+ { flexDirection: "column" },
132
+ createElement(Text, { key: "header" }, dim(headerText)),
133
+ snapshot.notice ? createElement(Text, { key: "notice" }, yellow(snapshot.notice)) : null,
134
+ createElement(
135
+ Box,
136
+ { key: "main", marginTop: 1, flexDirection: "row" },
137
+ createElement(Box, { width: leftWidth, flexDirection: "column", paddingRight: 1 }, ...treeElements),
138
+ createElement(Box, { width: rightWidth, flexDirection: "column", paddingLeft: 1 }, ...detailElements)
139
+ ),
140
+ summaryLines.length > 0 ? createElement(Text, { key: "summary-gap" }, "") : null,
141
+ ...summaryLines.map((line, index) => createElement(Text, { key: `summary-${index}` }, line)),
142
+ createElement(Text, { key: "footer-gap" }, ""),
143
+ createElement(Text, { key: "footer" }, dim(footerText))
144
+ );
145
+ }
146
+
147
+ export function buildHeaderText(snapshot) {
148
+ const progressText = snapshot.totalCount > 0 ? `[${snapshot.completedCount}/${snapshot.totalCount}]` : "[0/0]";
149
+ const phaseText = snapshot.phase || (snapshot.finished ? "run complete" : "preparing");
150
+ const modeText = snapshot.mode === "investigating"
151
+ ? `investigating with ${snapshot.agentSession?.provider || "agent"}`
152
+ : snapshot.finished
153
+ ? "interactive summary"
154
+ : "live run tree";
155
+ return [progressText, phaseText, modeText].filter(Boolean).join(" · ");
156
+ }
157
+
158
+ export function buildFooterText(snapshot) {
159
+ if (!snapshot.finished) return "Run in progress";
160
+ if (snapshot.mode === "investigating") {
161
+ if (snapshot.agentSession?.status === "running" || snapshot.agentSession?.status === "starting") {
162
+ return "x cancel investigation · q quit";
163
+ }
164
+ return "b back to summary · q quit";
165
+ }
166
+ if (!snapshot.selectedFailure) return "q quit";
167
+ return "↑/↓ select failure · y investigate · c Claude · o Codex · q quit";
168
+ }
169
+
170
+ function buildTreeElements({ snapshot, spinnerFrame, stdout }) {
171
+ const elements = [];
172
+ for (let serviceIndex = 0; serviceIndex < snapshot.services.length; serviceIndex += 1) {
173
+ const service = snapshot.services[serviceIndex];
174
+ const isLastService = serviceIndex === snapshot.services.length - 1;
175
+ const serviceConnector = isLastService ? TREE_LAST : TREE_BRANCH;
176
+
177
+ if (service.skipped) {
178
+ elements.push(
179
+ createElement(
180
+ Text,
181
+ { key: `svc-${service.name}` },
182
+ `${serviceConnector}${colorService(service.name)} ${dim(service.skipReason || "skipped")}`
183
+ )
184
+ );
185
+ continue;
186
+ }
187
+
188
+ elements.push(
189
+ createElement(Text, { key: `svc-${service.name}` }, `${serviceConnector}${colorService(service.name)}`)
190
+ );
191
+
192
+ const serviceIndent = isLastService ? TREE_SPACE : TREE_PIPE;
193
+ for (let typeIndex = 0; typeIndex < service.types.length; typeIndex += 1) {
194
+ const typeNode = service.types[typeIndex];
195
+ const isLastType = typeIndex === service.types.length - 1;
196
+ const typeConnector = isLastType ? TREE_LAST : TREE_BRANCH;
197
+
198
+ if (typeNode.collapsed) {
199
+ const totalFiles = typeNode.suites.reduce((sum, suite) => sum + suite.fileCount, 0);
200
+ const totalDuration = typeNode.suites.reduce((sum, suite) => sum + suite.totalDurationMs, 0);
201
+ const allSkipped = typeNode.suites.every((suite) => suite.collapseStatus === "all_skipped");
202
+ const icon = allSkipped ? yellow(figures.arrowDown) : green(figures.tick);
203
+ const suffix = allSkipped
204
+ ? dim(`(${totalFiles} files skipped)`)
205
+ : dim(`(${totalFiles} files) ${formatDuration(totalDuration)}`);
206
+ elements.push(
207
+ createElement(
208
+ Text,
209
+ { key: `type-${service.name}-${typeNode.type}` },
210
+ `${serviceIndent}${typeConnector}${colorTypeBadge(typeNode.type.toUpperCase())} ${icon} ${suffix}`
211
+ )
212
+ );
213
+ continue;
214
+ }
215
+
216
+ elements.push(
217
+ createElement(
218
+ Text,
219
+ { key: `type-${service.name}-${typeNode.type}` },
220
+ `${serviceIndent}${typeConnector}${colorTypeBadge(typeNode.type.toUpperCase())}`
221
+ )
222
+ );
223
+
224
+ const typeIndent = serviceIndent + (isLastType ? TREE_SPACE : TREE_PIPE);
225
+ for (let suiteIndex = 0; suiteIndex < typeNode.suites.length; suiteIndex += 1) {
226
+ const suite = typeNode.suites[suiteIndex];
227
+ const isLastSuite = suiteIndex === typeNode.suites.length - 1;
228
+ const suiteConnector = isLastSuite ? TREE_LAST : TREE_BRANCH;
229
+
230
+ if (suite.collapsed) {
231
+ const icon = suite.collapseStatus === "all_skipped" ? yellow(figures.arrowDown) : green(figures.tick);
232
+ const suffix = suite.collapseStatus === "all_skipped"
233
+ ? dim(`(${suite.fileCount} files skipped)`)
234
+ : dim(`(${suite.fileCount} files) ${formatDuration(suite.totalDurationMs)}`);
235
+ elements.push(
236
+ createElement(
237
+ Text,
238
+ { key: `suite-${service.name}-${suite.key}` },
239
+ `${typeIndent}${suiteConnector}${icon} ${bold(suite.groupLabel)} ${suffix}`
240
+ )
241
+ );
242
+ continue;
243
+ }
244
+
245
+ const mixedLabel =
246
+ suite.passedCount > 0 && suite.passedCount < suite.fileCount
247
+ ? dim(` (${suite.fileCount} files · ${suite.passedCount} passed)`)
248
+ : "";
249
+ elements.push(
250
+ createElement(
251
+ Text,
252
+ { key: `suite-${service.name}-${suite.key}` },
253
+ `${typeIndent}${suiteConnector}${bold(suite.groupLabel)}${mixedLabel}`
254
+ )
255
+ );
256
+
257
+ const suiteIndent = typeIndent + (isLastSuite ? TREE_SPACE : TREE_PIPE);
258
+ for (let fileIndex = 0; fileIndex < suite.visibleFiles.length; fileIndex += 1) {
259
+ const file = suite.visibleFiles[fileIndex];
260
+ const isLastFile = fileIndex === suite.visibleFiles.length - 1;
261
+ const fileConnector = isLastFile ? TREE_LAST : TREE_BRANCH;
262
+ const failureKey = `${service.name}::${file.path}`;
263
+ const selected = failureKey === snapshot.selectedFailureKey;
264
+ const pointer = selected ? `${bold(">")} ` : "";
265
+ const icon = statusIcon(file.status, spinnerFrame);
266
+ const duration = file.durationMs != null ? ` ${dim(formatDuration(file.durationMs))}` : "";
267
+ elements.push(
268
+ createElement(
269
+ Text,
270
+ { key: `file-${service.name}-${file.path}` },
271
+ `${suiteIndent}${fileConnector}${pointer}${icon} ${file.displayName}${duration}`
272
+ )
273
+ );
274
+
275
+ if (file.status !== "failed" || (!file.error && !file.failureDetails)) continue;
276
+ const failureLines = renderFailureBlock(
277
+ {
278
+ serviceName: service.name,
279
+ type: suite.key.split(":")[0],
280
+ file: file.path,
281
+ framework: suite.framework,
282
+ },
283
+ {
284
+ error: file.error,
285
+ failureDetails: file.failureDetails || [],
286
+ failed: true,
287
+ },
288
+ {
289
+ width: Math.max(40, Math.floor(getTerminalWidth(stdout, 100) * 0.55)),
290
+ regressionCatalog: snapshot.regressionCatalog,
291
+ }
292
+ );
293
+ const failureIndent = suiteIndent + (isLastFile ? TREE_SPACE : TREE_PIPE);
294
+ for (let failureIndex = 0; failureIndex < failureLines.length; failureIndex += 1) {
295
+ elements.push(
296
+ createElement(
297
+ Text,
298
+ { key: `failure-${service.name}-${file.path}-${failureIndex}` },
299
+ `${failureIndent}${failureLines[failureIndex]}`
300
+ )
301
+ );
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return elements;
308
+ }
309
+
310
+ function buildFailureDetailPane(snapshot, detailLines) {
311
+ if (!snapshot.selectedFailure) {
312
+ return [
313
+ createElement(Text, { key: "detail-title" }, bold("Selected Failure")),
314
+ createElement(Text, { key: "detail-empty" }, dim("No failed file selected.")),
315
+ ];
316
+ }
317
+
318
+ const lines = detailLines.length > 0 ? detailLines : ["Loading failure details..."];
319
+ return [
320
+ createElement(Text, { key: "detail-title" }, bold("Selected Failure")),
321
+ createElement(
322
+ Text,
323
+ { key: "detail-path" },
324
+ `${snapshot.selectedFailure.serviceName} · ${snapshot.selectedFailure.filePath}`
325
+ ),
326
+ createElement(Text, { key: "detail-gap" }, ""),
327
+ ...lines.slice(0, 30).map((line, index) => createElement(Text, { key: `detail-${index}` }, line)),
328
+ ];
329
+ }
330
+
331
+ function buildAgentPane(snapshot) {
332
+ const entries = snapshot.agentSession?.entries || [];
333
+ const status = snapshot.agentSession?.status || "idle";
334
+ const statusText =
335
+ status === "running" || status === "starting"
336
+ ? yellow(`Status: ${status}`)
337
+ : status === "error"
338
+ ? red(`Status: ${status}`)
339
+ : green(`Status: ${status}`);
340
+
341
+ const renderedEntries = entries.length === 0
342
+ ? [createElement(Text, { key: "agent-empty" }, dim("Waiting for agent output..."))]
343
+ : entries.slice(-32).map((entry, index) =>
344
+ createElement(Text, { key: `agent-${index}` }, formatAgentEntry(entry))
345
+ );
346
+
347
+ return [
348
+ createElement(Text, { key: "agent-title" }, bold("Investigation")),
349
+ createElement(Text, { key: "agent-status" }, statusText),
350
+ createElement(Text, { key: "agent-gap" }, ""),
351
+ ...renderedEntries,
352
+ ];
353
+ }
354
+
355
+ export function formatAgentEntry(entry) {
356
+ if (entry.kind === "tool") return yellow(`[tool] ${entry.text}`);
357
+ if (entry.kind === "status") return dim(`[status] ${entry.text}`);
358
+ if (entry.kind === "error") return red(`[error] ${entry.text}`);
359
+ return entry.text;
360
+ }
361
+
362
+ function statusIcon(status, spinnerFrame) {
363
+ if (status === "pending") return dim(figures.bullet);
364
+ if (status === "running") return spinnerFrame;
365
+ if (status === "passed") return green(figures.tick);
366
+ if (status === "failed") return red(figures.cross);
367
+ if (status === "skipped") return yellow(figures.arrowDown);
368
+ if (status === "not_run") return dim(figures.bullet);
369
+ return dim(figures.bullet);
370
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildFooterText, buildHeaderText, formatAgentEntry } from "./run-session-app.mjs";
3
+
4
+ describe("run session app helpers", () => {
5
+ it("renders header text for running and complete modes", () => {
6
+ expect(
7
+ buildHeaderText({
8
+ totalCount: 3,
9
+ completedCount: 1,
10
+ phase: "setup",
11
+ finished: false,
12
+ mode: "running",
13
+ })
14
+ ).toContain("[1/3]");
15
+
16
+ expect(
17
+ buildHeaderText({
18
+ totalCount: 3,
19
+ completedCount: 3,
20
+ phase: null,
21
+ finished: true,
22
+ mode: "complete",
23
+ })
24
+ ).toContain("interactive summary");
25
+ });
26
+
27
+ it("renders footer text for complete and investigating modes", () => {
28
+ expect(
29
+ buildFooterText({
30
+ finished: true,
31
+ mode: "complete",
32
+ selectedFailure: { filePath: "tests/api/users.int.testkit.ts" },
33
+ })
34
+ ).toContain("investigate");
35
+
36
+ expect(
37
+ buildFooterText({
38
+ finished: true,
39
+ mode: "investigating",
40
+ agentSession: { status: "running" },
41
+ })
42
+ ).toContain("cancel");
43
+ });
44
+
45
+ it("formats transcript entries by kind", () => {
46
+ expect(formatAgentEntry({ kind: "status", text: "Inspecting repository" })).toContain("[status]");
47
+ expect(formatAgentEntry({ kind: "tool", text: "Bash: rg failure" })).toContain("[tool]");
48
+ expect(formatAgentEntry({ kind: "assistant", text: "Likely root cause." })).toBe("Likely root cause.");
49
+ });
50
+ });