@elench/testkit 0.1.87 → 0.1.88

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.
@@ -0,0 +1,84 @@
1
+ import React, { createElement } from "react";
2
+ import { Command, Flags } from "@oclif/core";
3
+ import { render } from "ink";
4
+ import { sharedFlags, resolveConfigsForCommand } from "../command-helpers.mjs";
5
+ import { createAssistantState } from "../assistant/state.mjs";
6
+ import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
7
+
8
+ export default class AssistantCommand extends Command {
9
+ static summary = "Launch the interactive testkit assistant";
10
+
11
+ static enableJsonFlag = true;
12
+
13
+ static flags = {
14
+ ...sharedFlags,
15
+ provider: Flags.string({
16
+ description: "Assistant provider",
17
+ options: ["auto", "claude", "codex"],
18
+ default: "auto",
19
+ }),
20
+ pane: Flags.string({
21
+ description: "Initial workbench pane",
22
+ options: ["detail", "artifacts", "logs", "setup"],
23
+ default: "detail",
24
+ }),
25
+ file: Flags.string({
26
+ description: "Initial file selection",
27
+ }),
28
+ message: Flags.string({
29
+ description: "Run one assistant turn non-interactively",
30
+ }),
31
+ };
32
+
33
+ async run() {
34
+ const { flags } = await this.parse(AssistantCommand);
35
+ const { allConfigs } = await resolveConfigsForCommand(flags);
36
+ const productDir = allConfigs[0]?.productDir || process.cwd();
37
+ const assistantState = createAssistantState({
38
+ productDir,
39
+ provider: flags.provider,
40
+ initialPane: flags.pane,
41
+ configs: allConfigs,
42
+ });
43
+
44
+ await assistantState.loadLatestArtifact();
45
+ if (flags.file) {
46
+ try {
47
+ const artifact = loadLatestRunArtifact(productDir);
48
+ const subject = resolveFileSubject(artifact, flags.file, flags.service || null);
49
+ assistantState.revealFile(subject.service.name, subject.file.path);
50
+ } catch {
51
+ // Ignore missing initial selection.
52
+ }
53
+ } else if (flags.service) {
54
+ assistantState.revealService(flags.service);
55
+ }
56
+
57
+ const interactive = process.stdout.isTTY && !this.jsonEnabled() && !flags.message;
58
+ if (!interactive) {
59
+ if (flags.message) {
60
+ await assistantState.submitInput(flags.message);
61
+ }
62
+ const snapshot = assistantState.getSnapshot();
63
+ if (!this.jsonEnabled()) {
64
+ for (const message of snapshot.messages) {
65
+ this.log(`${message.role}: ${message.text}`);
66
+ }
67
+ }
68
+ return snapshot;
69
+ }
70
+
71
+ const { AssistantApp } = await import("../tui/assistant-app.mjs");
72
+ const app = render(
73
+ createElement(AssistantApp, {
74
+ assistantState,
75
+ stdout: process.stdout,
76
+ productDir,
77
+ }),
78
+ { stdout: process.stdout, exitOnCtrlC: false }
79
+ );
80
+
81
+ await app.waitUntilExit();
82
+ return assistantState.getSnapshot();
83
+ }
84
+ }
@@ -1,14 +1,13 @@
1
1
  export function normalizeCliArgs(argv) {
2
2
  const topLevelCommands = new Set([
3
+ "assistant",
3
4
  "run",
4
- "inspect",
5
5
  "status",
6
6
  "destroy",
7
7
  "cleanup",
8
8
  "discover",
9
9
  "typecheck",
10
10
  "doctor",
11
- "investigate",
12
11
  "browser",
13
12
  "db",
14
13
  "help",
@@ -41,19 +40,48 @@ export function normalizeCliArgs(argv) {
41
40
  ]);
42
41
  const positionals = findPositionals(argv, valueFlags);
43
42
  const firstPositional = positionals[0] || null;
43
+ const interactiveTty = process.stdout.isTTY;
44
+ const runFlagPresent = argv.some((value) =>
45
+ [
46
+ "--type",
47
+ "-t",
48
+ "--suite",
49
+ "-s",
50
+ "--file",
51
+ "-f",
52
+ "--workers",
53
+ "--file-timeout-seconds",
54
+ "--shard",
55
+ "--seed",
56
+ "--write-status",
57
+ "--allow-partial-status",
58
+ "--ignore-skip-rules",
59
+ "--output-mode",
60
+ "--debug",
61
+ ].includes(value)
62
+ );
44
63
  const shouldPrefixRun =
45
- !firstPositional ||
46
- runTypeShortcuts.has(firstPositional.value) ||
47
- !topLevelCommands.has(firstPositional.value);
64
+ (!firstPositional && !interactiveTty) ||
65
+ runTypeShortcuts.has(firstPositional?.value) ||
66
+ runFlagPresent ||
67
+ !topLevelCommands.has(firstPositional?.value);
48
68
 
49
- if (shouldPrefixRun) {
50
- return ["run", ...argv];
69
+ if (!firstPositional && interactiveTty && !runFlagPresent) {
70
+ return ["assistant", ...argv];
51
71
  }
52
72
 
53
- if (topLevelCommands.has(firstPositional.value) && argv[0] !== firstPositional.value) {
73
+ if (!shouldPrefixRun && topLevelCommands.has(firstPositional?.value) && argv[0] !== firstPositional.value) {
54
74
  return reorderCommandArgs(argv, positionals);
55
75
  }
56
76
 
77
+ if (!topLevelCommands.has(firstPositional?.value) && interactiveTty && !runFlagPresent) {
78
+ return ["assistant", "--message", argv.join(" ")];
79
+ }
80
+
81
+ if (shouldPrefixRun) {
82
+ return ["run", ...argv];
83
+ }
84
+
57
85
  return argv;
58
86
  }
59
87
 
@@ -0,0 +1,131 @@
1
+ import React, { createElement, useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useAnimation, useApp, useInput } from "ink";
3
+ import { bold, dim, yellow } from "../presentation/colors.mjs";
4
+ import { getTerminalWidth } from "../presentation/terminal-layout.mjs";
5
+ import { buildInspectPaneContent } from "./detail-pane.mjs";
6
+
7
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
8
+
9
+ export function AssistantApp({ assistantState, stdout, productDir } = {}) {
10
+ const { exit } = useApp();
11
+ const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
12
+ const { frame } = useAnimation({ interval: 80, isActive: snapshot.busy });
13
+
14
+ useEffect(() => {
15
+ const unsubscribe = assistantState.subscribe(() => {
16
+ setSnapshot(assistantState.getSnapshot());
17
+ });
18
+ return unsubscribe;
19
+ }, [assistantState]);
20
+
21
+ useInput((input, key) => {
22
+ if (key.ctrl && input === "c") {
23
+ exit();
24
+ return;
25
+ }
26
+ if (input === "q" && !snapshot.composer) {
27
+ exit();
28
+ return;
29
+ }
30
+ if (key.return) {
31
+ void assistantState.submitCurrentComposer();
32
+ return;
33
+ }
34
+ if (key.backspace || key.delete) {
35
+ assistantState.backspaceComposer();
36
+ return;
37
+ }
38
+ if (key.tab) {
39
+ const panes = ["detail", "artifacts", "logs", "setup"];
40
+ const index = panes.indexOf(snapshot.workbench.paneMode || "detail");
41
+ assistantState.setPaneMode(panes[(index + 1) % panes.length]);
42
+ return;
43
+ }
44
+ if (isPrintableInput(input, key)) {
45
+ assistantState.appendComposer(input);
46
+ }
47
+ });
48
+
49
+ const terminalWidth = getTerminalWidth(stdout, 100);
50
+ const transcriptWidth = Math.max(54, Math.floor(terminalWidth * 0.58));
51
+ const contextWidth = Math.max(26, terminalWidth - transcriptWidth - 1);
52
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
53
+ const contextPane = useMemo(
54
+ () =>
55
+ buildInspectPaneContent({
56
+ productDir,
57
+ snapshot: snapshot.workbench,
58
+ paneMode: snapshot.workbench.paneMode,
59
+ logTail: 12,
60
+ }),
61
+ [productDir, snapshot.workbench]
62
+ );
63
+
64
+ return createElement(
65
+ Box,
66
+ { flexDirection: "column" },
67
+ createElement(Text, null, dim(buildHeader(snapshot, spinner))),
68
+ snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
69
+ createElement(
70
+ Box,
71
+ { flexDirection: "row", marginTop: 1 },
72
+ createElement(
73
+ Box,
74
+ { width: transcriptWidth, flexDirection: "column", paddingRight: 1 },
75
+ ...buildTranscriptLines(snapshot).map((line, index) => createElement(Text, { key: `line-${index}` }, line))
76
+ ),
77
+ createElement(
78
+ Box,
79
+ { width: contextWidth, flexDirection: "column", paddingLeft: 1 },
80
+ createElement(Text, null, bold(contextPane.title)),
81
+ createElement(Text, null, ""),
82
+ ...contextPane.lines.slice(0, 34).map((line, index) => createElement(Text, { key: `pane-${index}` }, line))
83
+ )
84
+ ),
85
+ createElement(Text, null, ""),
86
+ createElement(Text, null, dim("Enter send · Tab cycle pane · q quit")),
87
+ createElement(Text, null, bold("> "), snapshot.composer || dim("Ask testkit or use /run, /file, /discover, /status"))
88
+ );
89
+ }
90
+
91
+ function buildHeader(snapshot, spinner) {
92
+ const workbench = snapshot.workbench;
93
+ const status = snapshot.busy ? `${spinner} ${snapshot.activeStatus || "working"}` : "ready";
94
+ const selection = workbench.selectedEntry?.label || workbench.selectedEntry?.filePath || "no selection";
95
+ return [`testkit assistant`, `provider ${snapshot.provider}`, status, selection].join(" · ");
96
+ }
97
+
98
+ function buildTranscriptLines(snapshot) {
99
+ const lines = [];
100
+ for (const message of snapshot.messages.slice(-28)) {
101
+ const prefix = rolePrefix(message.role, message.toolName);
102
+ const messageLines = String(message.text || "").split(/\r?\n/);
103
+ for (const [index, line] of messageLines.entries()) {
104
+ lines.push(index === 0 ? `${prefix}${line}` : ` ${line}`);
105
+ }
106
+ lines.push("");
107
+ }
108
+ if (snapshot.busy && snapshot.messages.length === 0) {
109
+ lines.push("assistant is starting...");
110
+ }
111
+ return lines.slice(-40);
112
+ }
113
+
114
+ function rolePrefix(role, toolName) {
115
+ if (role === "user") return "you> ";
116
+ if (role === "assistant") return "tk> ";
117
+ if (role === "tool") return `${toolName || "tool"}> `;
118
+ return "sys> ";
119
+ }
120
+
121
+ function isPrintableInput(input, key) {
122
+ return Boolean(
123
+ input &&
124
+ input.length === 1 &&
125
+ !key.ctrl &&
126
+ !key.meta &&
127
+ !key.return &&
128
+ !key.escape &&
129
+ !key.tab
130
+ );
131
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "Browser bridge helpers for testkit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@elench/testkit-protocol": "0.1.87"
25
+ "@elench/testkit-protocol": "0.1.88"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -52,7 +52,7 @@
52
52
  "oclif": {
53
53
  "bin": "testkit",
54
54
  "commands": "./lib/cli/commands",
55
- "default": "run",
55
+ "default": "assistant",
56
56
  "topicSeparator": " "
57
57
  },
58
58
  "scripts": {
@@ -82,10 +82,10 @@
82
82
  },
83
83
  "dependencies": {
84
84
  "@babel/code-frame": "^7.29.0",
85
- "@elench/next-analysis": "0.1.87",
86
- "@elench/testkit-bridge": "0.1.87",
87
- "@elench/testkit-protocol": "0.1.87",
88
- "@elench/ts-analysis": "0.1.87",
85
+ "@elench/next-analysis": "0.1.88",
86
+ "@elench/testkit-bridge": "0.1.88",
87
+ "@elench/testkit-protocol": "0.1.88",
88
+ "@elench/ts-analysis": "0.1.88",
89
89
  "@oclif/core": "^4.10.6",
90
90
  "esbuild": "^0.25.11",
91
91
  "execa": "^9.5.0",
@@ -1,124 +0,0 @@
1
- import React, { createElement } from "react";
2
- import { Command, Flags } from "@oclif/core";
3
- import { render } from "ink";
4
- import { sharedFlags } from "../command-helpers.mjs";
5
- import { loadCurrentRunArtifact, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
6
- import { createInspectState } from "../tui/inspect-state.mjs";
7
- import { InspectApp } from "../tui/inspect-app.mjs";
8
- import { buildInspectPaneContent } from "../tui/detail-pane.mjs";
9
- import { hydrateInspectStateFromArtifact } from "../tui/inspect-artifact-adapter.mjs";
10
-
11
- export default class InspectCommand extends Command {
12
- static summary = "Inspect the latest run or a live run in one unified interface";
13
-
14
- static enableJsonFlag = true;
15
-
16
- static flags = {
17
- ...sharedFlags,
18
- file: Flags.string({
19
- description: "File path to inspect; defaults to the first failed file",
20
- }),
21
- live: Flags.boolean({
22
- description: "Inspect the live run artifact instead of the latest completed run",
23
- default: false,
24
- }),
25
- pane: Flags.string({
26
- description: "Initial pane",
27
- options: ["detail", "artifacts", "logs", "setup"],
28
- default: "detail",
29
- }),
30
- "log-tail": Flags.integer({
31
- description: "Number of backend log lines to include in detail/log panes",
32
- default: 12,
33
- }),
34
- };
35
-
36
- async run() {
37
- const { flags } = await this.parse(InspectCommand);
38
- const productDir = flags.dir || process.cwd();
39
- const interactive = process.stdout.isTTY && !this.jsonEnabled();
40
-
41
- if (!interactive) {
42
- const artifact = loadArtifact(productDir, flags.live);
43
- const inspectState = createInspectState({ dataSource: flags.live ? "live" : "artifact" });
44
- hydrateInspectStateFromArtifact(inspectState, artifact);
45
- inspectState.setPaneMode(flags.pane);
46
- applyInitialSelection(inspectState, artifact, flags.file || null, flags.service || null);
47
- const snapshot = inspectState.getSnapshot();
48
- const pane = buildInspectPaneContent({
49
- productDir,
50
- snapshot,
51
- paneMode: flags.pane,
52
- logTail: flags["log-tail"],
53
- });
54
- const payload = {
55
- source: flags.live ? "live" : "latest",
56
- pane: flags.pane,
57
- selection: snapshot.selectedEntry,
58
- lines: pane.lines,
59
- data: pane.data,
60
- };
61
- if (!this.jsonEnabled()) {
62
- for (const line of pane.lines) {
63
- this.log(line);
64
- }
65
- }
66
- return payload;
67
- }
68
-
69
- const artifact = loadArtifact(productDir, flags.live);
70
- const inspectState = createInspectState({ dataSource: flags.live ? "live" : "artifact" });
71
- hydrateInspectStateFromArtifact(inspectState, artifact);
72
- inspectState.setPaneMode(flags.pane);
73
- applyInitialSelection(inspectState, artifact, flags.file || null, flags.service || null);
74
-
75
- const app = render(
76
- createElement(InspectApp, {
77
- inspectState,
78
- stdout: process.stdout,
79
- productDir,
80
- }),
81
- { stdout: process.stdout, exitOnCtrlC: false }
82
- );
83
-
84
- let interval = null;
85
- if (flags.live) {
86
- interval = setInterval(() => {
87
- try {
88
- const liveArtifact = loadCurrentRunArtifact(productDir);
89
- hydrateInspectStateFromArtifact(inspectState, liveArtifact);
90
- inspectState.setPaneMode(flags.pane);
91
- applyInitialSelection(inspectState, liveArtifact, flags.file || null, flags.service || null);
92
- } catch {
93
- // Ignore polling misses while the live artifact is being rewritten.
94
- }
95
- }, 1000);
96
- }
97
-
98
- try {
99
- await app.waitUntilExit();
100
- } finally {
101
- if (interval) clearInterval(interval);
102
- }
103
-
104
- return {
105
- source: flags.live ? "live" : "latest",
106
- pane: flags.pane,
107
- };
108
- }
109
- }
110
-
111
- function loadArtifact(productDir, live) {
112
- return live ? loadCurrentRunArtifact(productDir) : loadLatestRunArtifact(productDir);
113
- }
114
-
115
- function applyInitialSelection(inspectState, artifact, fileSelector, serviceFilter) {
116
- if (fileSelector) {
117
- const subject = resolveFileSubject(artifact, fileSelector, serviceFilter || null);
118
- inspectState.revealFile(subject.service.name, subject.file.path);
119
- return;
120
- }
121
- if (serviceFilter) {
122
- inspectState.revealService(serviceFilter);
123
- }
124
- }
@@ -1,87 +0,0 @@
1
- import { Args, Command, Flags } from "@oclif/core";
2
- import { sharedFlags } from "../command-helpers.mjs";
3
- import { loadCurrentRunArtifact, resolveFileSubject } from "../viewer.mjs";
4
- import { runInteractiveInvestigation, startHostedInvestigation } from "../agents/investigate.mjs";
5
-
6
- export default class InvestigateCommand extends Command {
7
- static summary = "Investigate a failed file from the latest run with Codex or Claude";
8
-
9
- static enableJsonFlag = true;
10
-
11
- static args = {
12
- file: Args.string({
13
- description: "Optional file path; defaults to the first failed file",
14
- required: false,
15
- }),
16
- };
17
-
18
- static flags = {
19
- ...sharedFlags,
20
- provider: Flags.string({
21
- description: "Agent provider to use",
22
- options: ["auto", "claude", "codex"],
23
- default: "auto",
24
- }),
25
- message: Flags.string({
26
- description: "Additional user instruction for the investigation prompt",
27
- }),
28
- handoff: Flags.boolean({
29
- description: "Launch the provider's native interactive TUI instead of hosted output",
30
- default: false,
31
- }),
32
- };
33
-
34
- async run() {
35
- const { args, flags } = await this.parse(InvestigateCommand);
36
- const productDir = flags.dir || process.cwd();
37
- const runArtifact = loadCurrentRunArtifact(productDir);
38
- const subject = resolveFileSubject(runArtifact, args.file || null, flags.service || null);
39
-
40
- if (flags.handoff) {
41
- const result = await runInteractiveInvestigation({
42
- productDir,
43
- serviceName: subject.service.name,
44
- filePath: subject.file.path,
45
- provider: flags.provider,
46
- userMessage: flags.message || null,
47
- });
48
- if (!this.jsonEnabled()) {
49
- this.log(`${result.provider} exited with code ${result.exitCode}`);
50
- }
51
- return result;
52
- }
53
-
54
- let finalText = "";
55
- const session = startHostedInvestigation({
56
- productDir,
57
- serviceName: subject.service.name,
58
- filePath: subject.file.path,
59
- provider: flags.provider,
60
- userMessage: flags.message || null,
61
- onEvent: this.jsonEnabled()
62
- ? null
63
- : (event) => {
64
- if (event.type === "status" || event.type === "tool") {
65
- this.log(event.type === "tool" ? `[tool] ${event.name}${event.detail ? `: ${event.detail}` : ""}` : `[status] ${event.message}`);
66
- } else if (event.type === "error") {
67
- this.error(event.message);
68
- }
69
- },
70
- });
71
- const result = await session.completion;
72
- finalText = result.finalText || "";
73
-
74
- if (!this.jsonEnabled() && finalText.trim()) {
75
- this.log("");
76
- this.log(finalText.trim());
77
- }
78
-
79
- return {
80
- provider: result.provider,
81
- exitCode: result.exitCode,
82
- file: subject.file.path,
83
- service: subject.service.name,
84
- finalText,
85
- };
86
- }
87
- }