@elench/testkit 0.1.97 → 0.1.99
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 +8 -8
- package/lib/app/browser-bridge.mjs +1 -1
- package/lib/cli/assistant/actions.mjs +333 -0
- package/lib/cli/assistant/app.mjs +25 -1
- package/lib/cli/assistant/command-observer.mjs +110 -0
- package/lib/cli/assistant/command-results.mjs +167 -0
- package/lib/cli/assistant/composer.mjs +1 -1
- package/lib/cli/assistant/context-pack.mjs +73 -6
- package/lib/cli/assistant/interactive.mjs +1 -1
- package/lib/cli/assistant/prompt-builder.mjs +15 -8
- package/lib/cli/{agents → assistant}/providers/claude.mjs +2 -3
- package/lib/cli/{agents → assistant}/providers/codex.mjs +2 -6
- package/lib/cli/{agents → assistant/providers}/index.mjs +5 -5
- package/lib/cli/assistant/session.mjs +36 -94
- package/lib/cli/assistant/slash-commands.mjs +22 -1
- package/lib/cli/assistant/state.mjs +187 -100
- package/lib/cli/assistant/view-model.mjs +1 -1
- package/lib/cli/command-flags.mjs +61 -0
- package/lib/cli/commands/assistant.mjs +4 -3
- package/lib/cli/commands/browser/serve.mjs +5 -23
- package/lib/cli/commands/cleanup.mjs +8 -2
- package/lib/cli/commands/db/snapshot/capture.mjs +8 -4
- package/lib/cli/commands/destroy.mjs +8 -2
- package/lib/cli/commands/discover.mjs +13 -32
- package/lib/cli/commands/doctor.mjs +17 -14
- package/lib/cli/commands/run.mjs +14 -3
- package/lib/cli/commands/status.mjs +14 -3
- package/lib/cli/commands/typecheck.mjs +12 -9
- package/lib/cli/{tui/inspect-app.mjs → components/blocks/run-tree.mjs} +29 -54
- package/lib/cli/{tui → components/primitives}/filter-bar.mjs +1 -1
- package/lib/cli/{presentation → components/primitives}/summary-box.mjs +1 -1
- package/lib/cli/config.mjs +63 -0
- package/lib/cli/entrypoint.mjs +14 -5
- package/lib/cli/operations/browser/serve/operation.mjs +23 -0
- package/lib/cli/operations/cleanup/operation.mjs +8 -0
- package/lib/cli/{db.mjs → operations/db/snapshot/capture/operation.mjs} +15 -9
- package/lib/cli/operations/destroy/operation.mjs +12 -0
- package/lib/cli/operations/discover/operation.mjs +32 -0
- package/lib/cli/operations/doctor/operation.mjs +5 -0
- package/lib/cli/operations/run/operation.mjs +129 -0
- package/lib/cli/operations/status/operation.mjs +7 -0
- package/lib/cli/operations/typecheck/operation.mjs +5 -0
- package/lib/cli/renderers/browser-serve/text.mjs +6 -0
- package/lib/cli/renderers/cleanup/text.mjs +3 -0
- package/lib/cli/renderers/db-snapshot-capture/text.mjs +3 -0
- package/lib/cli/renderers/destroy/text.mjs +3 -0
- package/lib/cli/{presentation/discovery-reporter.mjs → renderers/discover/report.mjs} +3 -3
- package/lib/cli/renderers/discover/text.mjs +7 -0
- package/lib/cli/renderers/doctor/text.mjs +7 -0
- package/lib/cli/{presentation/failure-presentation.mjs → renderers/run/failure.mjs} +6 -6
- package/lib/cli/renderers/run/interactive.mjs +119 -0
- package/lib/cli/{presentation/run-reporter.mjs → renderers/run/text-reporter.mjs} +5 -5
- package/lib/cli/renderers/status/text.mjs +7 -0
- package/lib/cli/renderers/typecheck/text.mjs +7 -0
- package/lib/cli/{tui/inspect-model.mjs → state/run/model.mjs} +11 -26
- package/lib/cli/{tui/inspect-state.mjs → state/run/state.mjs} +11 -18
- package/lib/cli/{tui → state/tree}/fuzzy-match.mjs +1 -1
- package/lib/cli/terminal/capabilities.mjs +33 -0
- package/lib/database/index.mjs +9 -21
- package/lib/{cli/viewer.mjs → results/artifacts.mjs} +1 -1
- package/lib/{cli/context-resources.mjs → results/context.mjs} +1 -1
- package/lib/runner/maintenance.mjs +25 -14
- package/lib/runner/readiness.mjs +5 -4
- package/lib/runner/state-io.mjs +10 -4
- 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 +7 -8
- package/lib/cli/assistant/protocol.mjs +0 -67
- package/lib/cli/assistant/tool-registry.mjs +0 -318
- package/lib/cli/command-helpers.mjs +0 -191
- package/lib/cli/presentation/tree-reporter.mjs +0 -96
- package/lib/cli/tui/inspect-artifact-adapter.mjs +0 -3
- package/lib/cli/tui/inspect-live-adapter.mjs +0 -15
- /package/lib/cli/{agents → assistant}/providers/shared.mjs +0 -0
- /package/lib/cli/{presentation/events-reporter.mjs → renderers/run/events.mjs} +0 -0
- /package/lib/cli/{presentation → terminal}/colors.mjs +0 -0
- /package/lib/cli/{presentation/terminal-layout.mjs → terminal/layout.mjs} +0 -0
- /package/lib/{cli/presentation → results}/code-frames.mjs +0 -0
package/README.md
CHANGED
|
@@ -87,14 +87,14 @@ is known, for example `[~96% remaining]`.
|
|
|
87
87
|
Natural-language turns still go through Codex or Claude, but `testkit` owns the
|
|
88
88
|
transcript, command execution surface, context files under `.testkit/assistant/`,
|
|
89
89
|
and rendering around `testkit`, `npm`, and `npx` commands. When the provider
|
|
90
|
-
runs testkit-managed commands, the assistant
|
|
91
|
-
|
|
92
|
-
new result immediately.
|
|
90
|
+
runs testkit-managed commands, the assistant observes real Testkit command
|
|
91
|
+
executions through command sidecars and run artifacts, then refreshes the latest
|
|
92
|
+
run state so follow-up questions can use the new result immediately.
|
|
93
93
|
|
|
94
94
|
Assistant provider coverage is tested against the real `codex` and `claude`
|
|
95
95
|
CLIs. The test suite assumes both are installed and authenticated; provider
|
|
96
|
-
adapter, assistant shell,
|
|
97
|
-
provider stand-in binaries or simulated provider sessions.
|
|
96
|
+
adapter, assistant shell, command-observation, and real testkit-run coverage do
|
|
97
|
+
not use provider stand-in binaries or simulated provider sessions.
|
|
98
98
|
|
|
99
99
|
Assistant runtime settings are repo-local. Use `/provider`, `/model`,
|
|
100
100
|
`/effort`, and `/settings` inside the assistant to inspect or change the active
|
|
@@ -105,9 +105,9 @@ has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
|
|
|
105
105
|
Backspace, Delete, Ctrl+D, and Ctrl+L to clear the visible transcript. Ctrl+C
|
|
106
106
|
quits the assistant.
|
|
107
107
|
|
|
108
|
-
The non-interactive `assistant --message ...` mode uses the same provider
|
|
109
|
-
|
|
110
|
-
it is not the primary interactive UX.
|
|
108
|
+
The non-interactive `assistant --message ...` mode uses the same provider
|
|
109
|
+
command-observation path for one hosted turn at a time. It is useful in scripts
|
|
110
|
+
and tests, but it is not the primary interactive UX.
|
|
111
111
|
|
|
112
112
|
Batch `run` output stays intentionally short: one line per completed file, a
|
|
113
113
|
concise failure block, and a final summary. Service logs, captured runtime
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfigContext, resolveProductDir } from "../config/index.mjs";
|
|
2
2
|
import { discoverTests } from "../discovery/index.mjs";
|
|
3
|
-
import { loadCurrentRunArtifact } from "../
|
|
3
|
+
import { loadCurrentRunArtifact } from "../results/artifacts.mjs";
|
|
4
4
|
|
|
5
5
|
export async function loadBrowserBridgeContext(options = {}) {
|
|
6
6
|
const productDir = resolveProductDir(process.cwd(), options.dir);
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { loadCurrentRunArtifact, loadLatestRunArtifact, resolveFileSubject } from "../../results/artifacts.mjs";
|
|
4
|
+
import { readContextContent } from "../../results/context.mjs";
|
|
5
|
+
import { buildRunRequest, executeRunRequest } from "../operations/run/operation.mjs";
|
|
6
|
+
import { executeDiscoverOperation } from "../operations/discover/operation.mjs";
|
|
7
|
+
import { executeDoctorOperation } from "../operations/doctor/operation.mjs";
|
|
8
|
+
import { executeStatusOperation } from "../operations/status/operation.mjs";
|
|
9
|
+
import { renderDiscoverResult } from "../renderers/discover/text.mjs";
|
|
10
|
+
import { renderDoctorResult } from "../renderers/doctor/text.mjs";
|
|
11
|
+
import { renderStatusResult } from "../renderers/status/text.mjs";
|
|
12
|
+
import { createRunSession } from "../renderers/run/interactive.mjs";
|
|
13
|
+
import { loadCliConfig } from "../config.mjs";
|
|
14
|
+
|
|
15
|
+
const FILE_LINE_LIMIT = 160;
|
|
16
|
+
|
|
17
|
+
export async function executeAssistantAction(type, args = {}, context = {}) {
|
|
18
|
+
switch (type) {
|
|
19
|
+
case "inspect":
|
|
20
|
+
return readContextAction({ file: args.file || null, mode: "detail" }, context);
|
|
21
|
+
case "logs":
|
|
22
|
+
return readContextAction({ service: args.service || null, mode: "logs" }, context);
|
|
23
|
+
case "artifacts":
|
|
24
|
+
return readContextAction({ file: args.file || null, mode: "artifacts" }, context);
|
|
25
|
+
case "setup":
|
|
26
|
+
return readContextAction({ service: args.service || null, mode: "setup" }, context);
|
|
27
|
+
case "file":
|
|
28
|
+
return readFileAction({ path: args.file || args.path }, context);
|
|
29
|
+
case "service":
|
|
30
|
+
return readContextAction({ service: args.service, mode: "detail" }, context);
|
|
31
|
+
case "status":
|
|
32
|
+
return statusAction(args, context);
|
|
33
|
+
case "discover":
|
|
34
|
+
return discoverAction(args, context);
|
|
35
|
+
case "doctor":
|
|
36
|
+
return doctorAction(args, context);
|
|
37
|
+
case "run":
|
|
38
|
+
return runAction(args.options || args, context);
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`Unsupported assistant action "${type}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readContextAction(args, context) {
|
|
45
|
+
if (args.file || args.path) {
|
|
46
|
+
ensureArtifactLoaded(context);
|
|
47
|
+
const artifact = context.runState.getSnapshot().runArtifact;
|
|
48
|
+
const subject = resolveFileSubject(artifact, args.file || args.path, args.service || null);
|
|
49
|
+
context.runState.revealFile(subject.service.name, subject.file.path);
|
|
50
|
+
} else if (args.service) {
|
|
51
|
+
ensureArtifactLoaded(context);
|
|
52
|
+
if (!context.runState.revealService(args.service)) {
|
|
53
|
+
throw new Error(`Unknown service "${args.service}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const content = readContextContent({
|
|
58
|
+
productDir: context.productDir,
|
|
59
|
+
snapshot: context.runState.getSnapshot(),
|
|
60
|
+
mode: normalizeContextMode(args.mode),
|
|
61
|
+
logTail: args.logTail == null ? 12 : Number(args.logTail),
|
|
62
|
+
});
|
|
63
|
+
context.commandLog?.refresh?.();
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
title: content.title,
|
|
67
|
+
text: content.lines.join("\n"),
|
|
68
|
+
data: {
|
|
69
|
+
title: content.title,
|
|
70
|
+
lines: content.lines,
|
|
71
|
+
selection: content.selection,
|
|
72
|
+
mode: content.mode,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readFileAction(args, context) {
|
|
78
|
+
const file = String(args.path || args.file || "").trim();
|
|
79
|
+
if (!file) throw new Error("read_file requires a path");
|
|
80
|
+
const resolved = path.resolve(context.productDir, file);
|
|
81
|
+
if (!resolved.startsWith(path.resolve(context.productDir))) {
|
|
82
|
+
throw new Error("read_file only supports paths inside the current repository");
|
|
83
|
+
}
|
|
84
|
+
if (!fs.existsSync(resolved)) {
|
|
85
|
+
throw new Error(`File not found: ${file}`);
|
|
86
|
+
}
|
|
87
|
+
const startLine = Math.max(1, Number(args.startLine || args.start || 1) || 1);
|
|
88
|
+
const requestedEnd = Number(args.endLine || args.end || startLine + FILE_LINE_LIMIT - 1) || startLine + FILE_LINE_LIMIT - 1;
|
|
89
|
+
const endLine = Math.max(startLine, Math.min(requestedEnd, startLine + FILE_LINE_LIMIT - 1));
|
|
90
|
+
const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
|
|
91
|
+
const selected = [];
|
|
92
|
+
for (let lineNumber = startLine; lineNumber <= Math.min(endLine, lines.length); lineNumber += 1) {
|
|
93
|
+
selected.push(`${lineNumber}: ${lines[lineNumber - 1]}`);
|
|
94
|
+
}
|
|
95
|
+
const title = `File ${path.relative(context.productDir, resolved) || path.basename(resolved)}`;
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
title,
|
|
99
|
+
text: selected.join("\n"),
|
|
100
|
+
data: {
|
|
101
|
+
path: resolved,
|
|
102
|
+
relativePath: path.relative(context.productDir, resolved),
|
|
103
|
+
startLine,
|
|
104
|
+
endLine,
|
|
105
|
+
lines: selected,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runAction(args, context) {
|
|
111
|
+
const normalizedArgs = normalizeRunActionArgs(args, context.productDir);
|
|
112
|
+
const request = await buildRunRequest(normalizedArgs, null, context.productDir, context.productDir);
|
|
113
|
+
const runSession = createRunSession({
|
|
114
|
+
productDir: request.productDir,
|
|
115
|
+
stderr: process.stderr,
|
|
116
|
+
config: loadCliConfig(request.productDir),
|
|
117
|
+
});
|
|
118
|
+
const invocationId = `slash-run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
119
|
+
const rawCommand = buildRunCommandLine(normalizedArgs);
|
|
120
|
+
context.commandLog?.appendCommandLog({
|
|
121
|
+
type: "command_start",
|
|
122
|
+
command: "testkit",
|
|
123
|
+
commandId: invocationId,
|
|
124
|
+
cwd: context.productDir,
|
|
125
|
+
raw: rawCommand,
|
|
126
|
+
});
|
|
127
|
+
context.onEvent?.({
|
|
128
|
+
type: "run-session-start",
|
|
129
|
+
tool: "run",
|
|
130
|
+
invocationId,
|
|
131
|
+
session: runSession,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await executeRunRequest(request, {
|
|
136
|
+
outputMode: "compact",
|
|
137
|
+
terminal: {
|
|
138
|
+
stdout: process.stdout,
|
|
139
|
+
stderr: process.stderr,
|
|
140
|
+
stdin: process.stdin,
|
|
141
|
+
env: context.env,
|
|
142
|
+
},
|
|
143
|
+
attachedRunSession: runSession,
|
|
144
|
+
});
|
|
145
|
+
context.runState.hydrateFromArtifact(result.runArtifact);
|
|
146
|
+
context.commandLog?.refresh?.();
|
|
147
|
+
context.commandLog?.appendCommandLog({
|
|
148
|
+
type: "command_exit",
|
|
149
|
+
command: "testkit",
|
|
150
|
+
commandId: invocationId,
|
|
151
|
+
cwd: context.productDir,
|
|
152
|
+
raw: rawCommand,
|
|
153
|
+
code: result.exitCode ?? 0,
|
|
154
|
+
signal: null,
|
|
155
|
+
});
|
|
156
|
+
context.onEvent?.({
|
|
157
|
+
type: "run-session-end",
|
|
158
|
+
tool: "run",
|
|
159
|
+
invocationId,
|
|
160
|
+
session: runSession,
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
ok: (result.exitCode ?? 0) === 0,
|
|
164
|
+
title: buildRunTitle(normalizedArgs),
|
|
165
|
+
text: [`Running ${rawCommand}`, formatRunSessionText(runSession)].filter(Boolean).join("\n"),
|
|
166
|
+
data: {
|
|
167
|
+
exitCode: result.exitCode ?? 0,
|
|
168
|
+
summaryRows: runSession.getSnapshot().summaryData?.rows || [],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
context.commandLog?.appendCommandLog({
|
|
173
|
+
type: "command_exit",
|
|
174
|
+
command: "testkit",
|
|
175
|
+
commandId: invocationId,
|
|
176
|
+
cwd: context.productDir,
|
|
177
|
+
raw: rawCommand,
|
|
178
|
+
code: error?.exitCode ?? 1,
|
|
179
|
+
signal: error?.signal ?? null,
|
|
180
|
+
});
|
|
181
|
+
context.onEvent?.({
|
|
182
|
+
type: "run-session-end",
|
|
183
|
+
tool: "run",
|
|
184
|
+
invocationId,
|
|
185
|
+
session: runSession,
|
|
186
|
+
});
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function discoverAction(args, context) {
|
|
192
|
+
const flags = {
|
|
193
|
+
dir: context.productDir,
|
|
194
|
+
service: normalizeOptionalString(args.service),
|
|
195
|
+
type: normalizeStringArray(args.type),
|
|
196
|
+
suite: normalizeStringArray(args.suite),
|
|
197
|
+
file: normalizeStringArray(args.file || args.path),
|
|
198
|
+
"runnable-only": Boolean(args.runnableOnly || args["runnable-only"]),
|
|
199
|
+
strict: Boolean(args.strict),
|
|
200
|
+
output: normalizeOptionalString(args.output),
|
|
201
|
+
};
|
|
202
|
+
const result = await executeDiscoverOperation(flags, context.productDir);
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
title: "testkit discover",
|
|
206
|
+
text: renderDiscoverResult(result, { outputMode: args.outputMode || "compact" }).join("\n"),
|
|
207
|
+
data: result,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function statusAction(args, context) {
|
|
212
|
+
const results = await executeStatusOperation({
|
|
213
|
+
dir: context.productDir,
|
|
214
|
+
service: normalizeOptionalString(args.service),
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
title: "testkit status",
|
|
219
|
+
text: results.flatMap(renderStatusResult).join("\n"),
|
|
220
|
+
data: { results },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function doctorAction(args, context) {
|
|
225
|
+
const result = await executeDoctorOperation({
|
|
226
|
+
dir: context.productDir,
|
|
227
|
+
typecheck: args.typecheck == null ? true : Boolean(args.typecheck),
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
ok: result.ok,
|
|
231
|
+
title: "testkit doctor",
|
|
232
|
+
text: renderDoctorResult(result).join("\n"),
|
|
233
|
+
data: result,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeRunActionArgs(args, productDir) {
|
|
238
|
+
return {
|
|
239
|
+
dir: productDir,
|
|
240
|
+
service: normalizeOptionalString(args.service),
|
|
241
|
+
type: normalizeStringArray(args.type),
|
|
242
|
+
suite: normalizeStringArray(args.suite),
|
|
243
|
+
file: normalizeStringArray(args.file || args.path),
|
|
244
|
+
workers: normalizeOptionalString(args.workers),
|
|
245
|
+
"file-timeout-seconds": normalizeOptionalString(args.fileTimeoutSeconds || args["file-timeout-seconds"]),
|
|
246
|
+
shard: normalizeOptionalString(args.shard),
|
|
247
|
+
seed: normalizeOptionalString(args.seed),
|
|
248
|
+
"write-status": Boolean(args.writeStatus || args["write-status"]),
|
|
249
|
+
"allow-partial-status": Boolean(args.allowPartialStatus || args["allow-partial-status"]),
|
|
250
|
+
"ignore-skip-rules": Boolean(args.ignoreSkipRules || args["ignore-skip-rules"]),
|
|
251
|
+
"output-mode": "compact",
|
|
252
|
+
debug: false,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildRunTitle(args) {
|
|
257
|
+
const types = args.type?.length ? args.type.join(",") : "all";
|
|
258
|
+
return `testkit run ${types}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildRunCommandLine(args) {
|
|
262
|
+
const parts = ["testkit", "run"];
|
|
263
|
+
if (args.type?.length === 1) parts.push(args.type[0]);
|
|
264
|
+
parts.push("--dir", ".");
|
|
265
|
+
if (args.type?.length !== 1) {
|
|
266
|
+
for (const type of args.type || []) parts.push("--type", type);
|
|
267
|
+
}
|
|
268
|
+
for (const suite of args.suite || []) parts.push("--suite", suite);
|
|
269
|
+
for (const file of args.file || []) parts.push("--file", file);
|
|
270
|
+
if (args.service) parts.push("--service", args.service);
|
|
271
|
+
return parts.join(" ");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formatRunSessionText(runSession) {
|
|
275
|
+
const snapshot = runSession.getSnapshot();
|
|
276
|
+
const fileLines = collectRunTreeFiles(snapshot.services || [])
|
|
277
|
+
.map((entry) => `${formatRunStatus(entry.status)} ${entry.serviceName} ${entry.type} ${entry.filePath || entry.path}`);
|
|
278
|
+
const rows = snapshot.summaryData?.rows || [];
|
|
279
|
+
const summaryLines = rows.map(([label, value]) => `${label}: ${value}`);
|
|
280
|
+
const lines = [...fileLines, ...summaryLines];
|
|
281
|
+
return lines.length > 0 ? lines.join("\n") : "Run complete.";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function collectRunTreeFiles(services) {
|
|
285
|
+
const files = [];
|
|
286
|
+
for (const service of services || []) {
|
|
287
|
+
for (const typeNode of service.types || []) {
|
|
288
|
+
for (const suite of typeNode.suites || []) {
|
|
289
|
+
for (const file of suite.files || []) {
|
|
290
|
+
files.push(file);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return files;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatRunStatus(status) {
|
|
299
|
+
if (status === "passed") return "PASS";
|
|
300
|
+
if (status === "failed") return "FAIL";
|
|
301
|
+
if (status === "skipped") return "SKIP";
|
|
302
|
+
if (status === "running") return "RUN";
|
|
303
|
+
if (status === "not_run") return "NOT_RUN";
|
|
304
|
+
return "PENDING";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function ensureArtifactLoaded(context) {
|
|
308
|
+
const snapshot = context.runState.getSnapshot();
|
|
309
|
+
if (snapshot.runArtifact) return snapshot.runArtifact;
|
|
310
|
+
try {
|
|
311
|
+
context.runState.hydrateFromArtifact(loadCurrentRunArtifact(context.productDir));
|
|
312
|
+
} catch {
|
|
313
|
+
context.runState.hydrateFromArtifact(loadLatestRunArtifact(context.productDir));
|
|
314
|
+
}
|
|
315
|
+
return context.runState.getSnapshot().runArtifact;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeContextMode(mode) {
|
|
319
|
+
if (mode === "logs" || mode === "artifacts" || mode === "setup") return mode;
|
|
320
|
+
return "detail";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeOptionalString(value) {
|
|
324
|
+
if (typeof value !== "string") return null;
|
|
325
|
+
const normalized = value.trim();
|
|
326
|
+
return normalized.length > 0 ? normalized : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeStringArray(value) {
|
|
330
|
+
if (Array.isArray(value)) return value.flatMap((entry) => String(entry).split(",")).map((entry) => entry.trim()).filter(Boolean);
|
|
331
|
+
if (value == null) return [];
|
|
332
|
+
return String(value).split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
333
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { createElement, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Box, Text, useApp, useBoxMetrics, useCursor, useInput, useStdout } from "ink";
|
|
3
|
-
import { bold, cyan, dim, green, red, yellow } from "../
|
|
3
|
+
import { bold, cyan, dim, green, red, yellow } from "../terminal/colors.mjs";
|
|
4
|
+
import { RunTreeView } from "../components/blocks/run-tree.mjs";
|
|
4
5
|
import { getComposerDisplayModel } from "./composer.mjs";
|
|
5
6
|
import { buildAssistantViewModel } from "./view-model.mjs";
|
|
6
7
|
|
|
@@ -49,6 +50,7 @@ export function AssistantApp({
|
|
|
49
50
|
}),
|
|
50
51
|
[snapshot, stdout?.columns]
|
|
51
52
|
);
|
|
53
|
+
const runSession = assistantState.getLiveRunSession?.() || assistantState.getLastRunSession?.() || null;
|
|
52
54
|
|
|
53
55
|
return createElement(
|
|
54
56
|
Box,
|
|
@@ -63,6 +65,28 @@ export function AssistantApp({
|
|
|
63
65
|
view.blocks.length === 0
|
|
64
66
|
? createElement(WelcomePanel, { view })
|
|
65
67
|
: createElement(Transcript, { view }),
|
|
68
|
+
runSession
|
|
69
|
+
? createElement(
|
|
70
|
+
Box,
|
|
71
|
+
{ flexDirection: "column", marginTop: 1 },
|
|
72
|
+
createElement(Text, null, bold("Run Session")),
|
|
73
|
+
createElement(
|
|
74
|
+
Box,
|
|
75
|
+
{
|
|
76
|
+
borderStyle: "round",
|
|
77
|
+
flexDirection: "column",
|
|
78
|
+
paddingLeft: 1,
|
|
79
|
+
paddingRight: 1,
|
|
80
|
+
},
|
|
81
|
+
createElement(RunTreeView, {
|
|
82
|
+
runState: runSession.runState,
|
|
83
|
+
stdout,
|
|
84
|
+
productDir: runSession.productDir,
|
|
85
|
+
interactive: false,
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
: null,
|
|
66
90
|
createElement(Text, null, ""),
|
|
67
91
|
createElement(ComposerBar, { view, busy: snapshot.busy }),
|
|
68
92
|
createElement(Text, null, dim(view.statusLine)),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { loadCurrentRunArtifact, loadLatestRunArtifact } from "../../results/artifacts.mjs";
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL_MS = 150;
|
|
6
|
+
const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
|
|
7
|
+
|
|
8
|
+
export function createAssistantCommandObserver({
|
|
9
|
+
productDir,
|
|
10
|
+
commandLog,
|
|
11
|
+
runState,
|
|
12
|
+
onEvent,
|
|
13
|
+
intervalMs = POLL_INTERVAL_MS,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const seenResultFiles = new Set();
|
|
16
|
+
let timer = null;
|
|
17
|
+
let running = false;
|
|
18
|
+
let lastArtifactSignature = null;
|
|
19
|
+
|
|
20
|
+
function start() {
|
|
21
|
+
if (running) return;
|
|
22
|
+
running = true;
|
|
23
|
+
lastArtifactSignature = readArtifactSignature();
|
|
24
|
+
scan();
|
|
25
|
+
timer = setInterval(scan, intervalMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stop() {
|
|
29
|
+
running = false;
|
|
30
|
+
if (timer) clearInterval(timer);
|
|
31
|
+
timer = null;
|
|
32
|
+
scan();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scan() {
|
|
36
|
+
observeCommandResults();
|
|
37
|
+
observeRunArtifact();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function observeCommandResults() {
|
|
41
|
+
const resultDir = commandLog?.resultDir;
|
|
42
|
+
if (!resultDir || !fs.existsSync(resultDir)) return;
|
|
43
|
+
const entries = fs.readdirSync(resultDir, { withFileTypes: true })
|
|
44
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
45
|
+
.map((entry) => path.join(resultDir, entry.name))
|
|
46
|
+
.sort();
|
|
47
|
+
|
|
48
|
+
for (const filePath of entries) {
|
|
49
|
+
if (seenResultFiles.has(filePath)) continue;
|
|
50
|
+
seenResultFiles.add(filePath);
|
|
51
|
+
let document = null;
|
|
52
|
+
try {
|
|
53
|
+
document = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!OBSERVED_KINDS.has(document.kind)) continue;
|
|
58
|
+
if (document.kind === "run") {
|
|
59
|
+
hydrateRunArtifact("command-result", document);
|
|
60
|
+
}
|
|
61
|
+
onEvent?.({
|
|
62
|
+
type: "observed-testkit-command",
|
|
63
|
+
command: document,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function observeRunArtifact() {
|
|
69
|
+
const signature = readArtifactSignature();
|
|
70
|
+
if (!signature) return;
|
|
71
|
+
if (signature === lastArtifactSignature) return;
|
|
72
|
+
lastArtifactSignature = signature;
|
|
73
|
+
hydrateRunArtifact("artifact", { artifactPath: signature.split(":")[0] });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readArtifactSignature() {
|
|
77
|
+
const artifactPath = path.join(productDir, ".testkit", "results", "live.json");
|
|
78
|
+
const latestPath = path.join(productDir, ".testkit", "results", "latest.json");
|
|
79
|
+
const candidate = fs.existsSync(artifactPath) ? artifactPath : latestPath;
|
|
80
|
+
if (!fs.existsSync(candidate)) return null;
|
|
81
|
+
const stat = fs.statSync(candidate);
|
|
82
|
+
return `${candidate}:${stat.mtimeMs}:${stat.size}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hydrateRunArtifact(source, command = null) {
|
|
86
|
+
let artifact = null;
|
|
87
|
+
try {
|
|
88
|
+
artifact = loadCurrentRunArtifact(productDir);
|
|
89
|
+
} catch {
|
|
90
|
+
try {
|
|
91
|
+
artifact = loadLatestRunArtifact(productDir);
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
runState?.hydrateFromArtifact?.(artifact);
|
|
97
|
+
onEvent?.({
|
|
98
|
+
type: "observed-run-artifact",
|
|
99
|
+
source,
|
|
100
|
+
artifact,
|
|
101
|
+
command,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
start,
|
|
107
|
+
stop,
|
|
108
|
+
scan,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export const ASSISTANT_SESSION_ENV = "TESTKIT_ASSISTANT_SESSION_ID";
|
|
5
|
+
export const ASSISTANT_RESULT_DIR_ENV = "TESTKIT_ASSISTANT_RESULT_DIR";
|
|
6
|
+
export const ASSISTANT_COMMAND_LOG_ENV = "TESTKIT_ASSISTANT_COMMAND_LOG";
|
|
7
|
+
export const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
|
|
8
|
+
export const ASSISTANT_WRAPPER_LOGGED_ENV = "TESTKIT_ASSISTANT_WRAPPER_LOGGED";
|
|
9
|
+
|
|
10
|
+
export function createAssistantCommandContext({
|
|
11
|
+
kind,
|
|
12
|
+
argv = process.argv.slice(2),
|
|
13
|
+
cwd = process.cwd(),
|
|
14
|
+
env = process.env,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const sessionId = env[ASSISTANT_SESSION_ENV] || null;
|
|
17
|
+
const resultDir = env[ASSISTANT_RESULT_DIR_ENV] || null;
|
|
18
|
+
const commandLogPath = env[ASSISTANT_COMMAND_LOG_ENV] || null;
|
|
19
|
+
if (!sessionId && !resultDir && !commandLogPath) return null;
|
|
20
|
+
|
|
21
|
+
const commandId = env[ASSISTANT_COMMAND_ID_ENV] || `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
22
|
+
const startedAt = new Date().toISOString();
|
|
23
|
+
return {
|
|
24
|
+
sessionId,
|
|
25
|
+
resultDir,
|
|
26
|
+
commandLogPath,
|
|
27
|
+
commandId,
|
|
28
|
+
kind: kind || inferCommandKind(argv),
|
|
29
|
+
argv: Array.isArray(argv) ? argv.map(String) : [],
|
|
30
|
+
cwd,
|
|
31
|
+
startedAt,
|
|
32
|
+
wrapperLogged: env[ASSISTANT_WRAPPER_LOGGED_ENV] === "1",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function withAssistantCommandResult(kind, callback, options = {}) {
|
|
37
|
+
const context = createAssistantCommandContext({ ...options, kind });
|
|
38
|
+
if (!context) return callback();
|
|
39
|
+
|
|
40
|
+
if (!context.wrapperLogged) {
|
|
41
|
+
appendAssistantCommandLog(context, {
|
|
42
|
+
type: "command_start",
|
|
43
|
+
commandId: context.commandId,
|
|
44
|
+
command: "testkit",
|
|
45
|
+
kind: context.kind,
|
|
46
|
+
argv: context.argv,
|
|
47
|
+
cwd: context.cwd,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const result = await callback();
|
|
53
|
+
const exitCode = inferExitCode(result);
|
|
54
|
+
writeAssistantCommandResult(context, {
|
|
55
|
+
ok: exitCode === 0,
|
|
56
|
+
exitCode,
|
|
57
|
+
result,
|
|
58
|
+
});
|
|
59
|
+
if (!context.wrapperLogged) {
|
|
60
|
+
appendAssistantCommandLog(context, {
|
|
61
|
+
type: "command_exit",
|
|
62
|
+
commandId: context.commandId,
|
|
63
|
+
command: "testkit",
|
|
64
|
+
kind: context.kind,
|
|
65
|
+
argv: context.argv,
|
|
66
|
+
cwd: context.cwd,
|
|
67
|
+
code: exitCode,
|
|
68
|
+
signal: null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
74
|
+
writeAssistantCommandResult(context, {
|
|
75
|
+
ok: false,
|
|
76
|
+
exitCode,
|
|
77
|
+
error: serializeError(error),
|
|
78
|
+
result: error?.result || null,
|
|
79
|
+
});
|
|
80
|
+
if (!context.wrapperLogged) {
|
|
81
|
+
appendAssistantCommandLog(context, {
|
|
82
|
+
type: "command_exit",
|
|
83
|
+
commandId: context.commandId,
|
|
84
|
+
command: "testkit",
|
|
85
|
+
kind: context.kind,
|
|
86
|
+
argv: context.argv,
|
|
87
|
+
cwd: context.cwd,
|
|
88
|
+
code: exitCode,
|
|
89
|
+
signal: error?.signal || null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function writeAssistantCommandResult(context, payload = {}) {
|
|
97
|
+
if (!context?.resultDir) return null;
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(context.resultDir, { recursive: true });
|
|
100
|
+
const finishedAt = new Date().toISOString();
|
|
101
|
+
const document = {
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
source: "testkit-command-result",
|
|
104
|
+
sessionId: context.sessionId,
|
|
105
|
+
commandId: context.commandId,
|
|
106
|
+
kind: context.kind,
|
|
107
|
+
argv: context.argv,
|
|
108
|
+
cwd: context.cwd,
|
|
109
|
+
startedAt: context.startedAt,
|
|
110
|
+
finishedAt,
|
|
111
|
+
...payload,
|
|
112
|
+
};
|
|
113
|
+
const filePath = path.join(context.resultDir, `${safeFileSegment(context.commandId)}-${safeFileSegment(context.kind)}.json`);
|
|
114
|
+
fs.writeFileSync(filePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
|
115
|
+
return filePath;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function appendAssistantCommandLog(context, event) {
|
|
122
|
+
const commandLogPath = context?.commandLogPath || process.env[ASSISTANT_COMMAND_LOG_ENV] || null;
|
|
123
|
+
if (!commandLogPath || !event) return;
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(path.dirname(commandLogPath), { recursive: true });
|
|
126
|
+
fs.appendFileSync(
|
|
127
|
+
commandLogPath,
|
|
128
|
+
`${JSON.stringify({ timestamp: new Date().toISOString(), sessionId: context?.sessionId || null, ...event })}\n`,
|
|
129
|
+
"utf8"
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
// Command logging must never make a user command fail.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function inferCommandKind(argv) {
|
|
137
|
+
const positionals = (Array.isArray(argv) ? argv : []).filter((arg) => !String(arg).startsWith("-"));
|
|
138
|
+
return positionals[0] || "run";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function inferExitCode(result) {
|
|
142
|
+
if (Number.isInteger(result?.exitCode)) return result.exitCode;
|
|
143
|
+
if (result?.ok === false) return 1;
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function serializeError(error) {
|
|
148
|
+
if (error instanceof Error) {
|
|
149
|
+
return {
|
|
150
|
+
name: error.name,
|
|
151
|
+
message: error.message,
|
|
152
|
+
stack: error.stack || null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
name: "Error",
|
|
157
|
+
message: String(error),
|
|
158
|
+
stack: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeFileSegment(value) {
|
|
163
|
+
return String(value || "command")
|
|
164
|
+
.trim()
|
|
165
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
166
|
+
.replace(/^-+|-+$/g, "") || "command";
|
|
167
|
+
}
|