@elench/testkit 0.1.93 → 0.1.96
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 +20 -7
- package/lib/cli/agents/providers/claude.mjs +28 -5
- package/lib/cli/agents/providers/shared.mjs +5 -0
- package/lib/cli/assistant/app.mjs +112 -83
- package/lib/cli/assistant/command-plan.mjs +227 -0
- package/lib/cli/assistant/context-pack.mjs +3 -3
- package/lib/cli/assistant/context-window.mjs +69 -0
- package/lib/cli/assistant/prompt-builder.mjs +4 -1
- package/lib/cli/assistant/session.mjs +7 -0
- package/lib/cli/assistant/state.mjs +55 -2
- package/lib/cli/assistant/tool-registry.mjs +35 -42
- package/lib/cli/assistant/view-model.mjs +132 -0
- package/lib/runtime-src/k6/http-checks.js +17 -59
- package/lib/runtime-src/shared/http-check-plan.mjs +53 -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 +7 -6
package/README.md
CHANGED
|
@@ -78,11 +78,23 @@ npx @elench/testkit db snapshot capture --service api --output scripts/testkit/s
|
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
`testkit` is assistant-first in an interactive TTY. The interactive assistant
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
opens with a repo-aware landing panel: provider/model, current directory,
|
|
82
|
+
latest run result, focused file/service, regression counts, and suggested next
|
|
83
|
+
prompts. The bottom composer is the primary interaction surface, and the status
|
|
84
|
+
line shows approximate context remaining when the active provider/model window
|
|
85
|
+
is known, for example `[~96% remaining]`.
|
|
86
|
+
|
|
87
|
+
Natural-language turns still go through Codex or Claude, but `testkit` owns the
|
|
88
|
+
transcript, command execution surface, context files under `.testkit/assistant/`,
|
|
89
|
+
and rendering around `testkit`, `npm`, and `npx` commands. When the provider
|
|
90
|
+
runs testkit-managed commands, the assistant records structured tool lifecycle
|
|
91
|
+
blocks and refreshes the latest run artifact so follow-up questions can use the
|
|
92
|
+
new result immediately.
|
|
93
|
+
|
|
94
|
+
Assistant provider coverage is tested against the real `codex` and `claude`
|
|
95
|
+
CLIs. The test suite assumes both are installed and authenticated; provider
|
|
96
|
+
adapter, assistant shell, `shell_exec`, and real testkit-run coverage do not use
|
|
97
|
+
provider stand-in binaries or simulated provider sessions.
|
|
86
98
|
|
|
87
99
|
Assistant runtime settings are repo-local. Use `/provider`, `/model`,
|
|
88
100
|
`/effort`, and `/settings` inside the assistant to inspect or change the active
|
|
@@ -90,7 +102,8 @@ provider runtime; changes are persisted to `.testkit/assistant/settings.json`.
|
|
|
90
102
|
CLI flags such as `--provider`, `--model`, `--effort`, and repeatable
|
|
91
103
|
`--provider-arg` override those settings for the current launch. The composer
|
|
92
104
|
has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
|
|
93
|
-
Backspace, Delete, and Ctrl+
|
|
105
|
+
Backspace, Delete, Ctrl+D, and Ctrl+L to clear the visible transcript. Ctrl+C
|
|
106
|
+
quits the assistant.
|
|
94
107
|
|
|
95
108
|
The non-interactive `assistant --message ...` mode uses the same provider/tool
|
|
96
109
|
engine for one hosted turn at a time. It is useful in scripts and tests, but
|
|
@@ -268,7 +281,7 @@ File-local execution metadata now lives next to the test when possible:
|
|
|
268
281
|
import { defineFile } from "@elench/testkit/config";
|
|
269
282
|
|
|
270
283
|
export const testkit = defineFile({
|
|
271
|
-
skip: "Billing is
|
|
284
|
+
skip: "Billing is currently unavailable locally",
|
|
272
285
|
locks: ["global-worker-loop"],
|
|
273
286
|
});
|
|
274
287
|
```
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
buildStatusEvent,
|
|
5
5
|
buildToolEvent,
|
|
6
6
|
createHostedSessionRunner,
|
|
7
|
-
extractTextFragments,
|
|
8
7
|
} from "./shared.mjs";
|
|
9
8
|
|
|
10
9
|
export function startClaudeHostedSession({
|
|
@@ -21,6 +20,7 @@ export function startClaudeHostedSession({
|
|
|
21
20
|
"-p",
|
|
22
21
|
"--output-format",
|
|
23
22
|
"stream-json",
|
|
23
|
+
"--verbose",
|
|
24
24
|
"--include-partial-messages",
|
|
25
25
|
];
|
|
26
26
|
|
|
@@ -73,6 +73,25 @@ function parseClaudePayload(payload) {
|
|
|
73
73
|
return events;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
if (type === "stream_event") {
|
|
77
|
+
const streamEvent = payload.event || {};
|
|
78
|
+
if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "text_delta") {
|
|
79
|
+
const text = String(streamEvent.delta.text || "");
|
|
80
|
+
if (text) events.push({ type: "delta", text });
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
if (streamEvent.type === "tool_use" || streamEvent.content_block?.type === "tool_use") {
|
|
84
|
+
const tool = streamEvent.content_block || streamEvent;
|
|
85
|
+
const event = buildToolEvent(
|
|
86
|
+
tool.name || tool.tool_name || streamEvent.type,
|
|
87
|
+
tool.input ? JSON.stringify(tool.input) : null
|
|
88
|
+
);
|
|
89
|
+
if (event) events.push(event);
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
return events;
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
if (type && /tool/i.test(type)) {
|
|
77
96
|
const event = buildToolEvent(
|
|
78
97
|
payload.name || payload.tool_name || payload.tool || type,
|
|
@@ -82,10 +101,14 @@ function parseClaudePayload(payload) {
|
|
|
82
101
|
return events;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
if (type === "assistant") {
|
|
105
|
+
return events;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (type === "result") {
|
|
109
|
+
if (payload.is_error || payload.subtype === "error") {
|
|
110
|
+
const event = buildErrorEvent(payload.result || payload.error || "Claude command failed");
|
|
111
|
+
if (event) events.push(event);
|
|
89
112
|
}
|
|
90
113
|
return events;
|
|
91
114
|
}
|
|
@@ -36,6 +36,11 @@ export function createHostedSessionRunner({ provider, child, onEvent, parsePaylo
|
|
|
36
36
|
const completion = (async () => {
|
|
37
37
|
const result = await child;
|
|
38
38
|
const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
|
|
39
|
+
if ((result.exitCode ?? 0) !== 0 && !finalText) {
|
|
40
|
+
const message = result.stderr || result.stdout || `${provider} exited with code ${result.exitCode ?? 1}`;
|
|
41
|
+
emit({ type: "error", message });
|
|
42
|
+
throw new Error(message);
|
|
43
|
+
}
|
|
39
44
|
if (finalText) emit({ type: "final", text: finalText });
|
|
40
45
|
emit({ type: "exit", code: result.exitCode ?? 0 });
|
|
41
46
|
settled = true;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { Box, Text, useApp, useInput } from "ink";
|
|
3
|
-
import { bold, dim, green, red, yellow } from "../presentation/colors.mjs";
|
|
2
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
3
|
+
import { bold, cyan, dim, green, red, yellow } from "../presentation/colors.mjs";
|
|
4
4
|
import { getComposerRenderParts } from "./composer.mjs";
|
|
5
|
+
import { buildAssistantViewModel } from "./view-model.mjs";
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const MAX_BLOCK_LINES = 18;
|
|
7
8
|
|
|
8
9
|
export function AssistantApp({
|
|
9
10
|
assistantState,
|
|
@@ -13,6 +14,7 @@ export function AssistantApp({
|
|
|
13
14
|
onRequestClose,
|
|
14
15
|
} = {}) {
|
|
15
16
|
const { exit } = useApp();
|
|
17
|
+
const { stdout } = useStdout();
|
|
16
18
|
const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
|
|
17
19
|
const [initialPromptStarted, setInitialPromptStarted] = useState(false);
|
|
18
20
|
const [initialPromptFinished, setInitialPromptFinished] = useState(false);
|
|
@@ -30,7 +32,7 @@ export function AssistantApp({
|
|
|
30
32
|
Promise.resolve(assistantState.submitInput(initialPrompt)).finally(() => {
|
|
31
33
|
setInitialPromptFinished(true);
|
|
32
34
|
});
|
|
33
|
-
}, [assistantState,
|
|
35
|
+
}, [assistantState, initialPrompt, initialPromptStarted]);
|
|
34
36
|
|
|
35
37
|
useEffect(() => {
|
|
36
38
|
if (!exitAfterInitialPrompt || !initialPromptFinished || snapshot.busy) return;
|
|
@@ -40,9 +42,12 @@ export function AssistantApp({
|
|
|
40
42
|
return () => clearTimeout(timer);
|
|
41
43
|
}, [exit, exitAfterInitialPrompt, initialPromptFinished, onRequestClose, snapshot.busy]);
|
|
42
44
|
|
|
43
|
-
const
|
|
44
|
-
() => snapshot
|
|
45
|
-
|
|
45
|
+
const view = useMemo(
|
|
46
|
+
() => buildAssistantViewModel(snapshot, {
|
|
47
|
+
cwd: snapshot.productDir || process.cwd(),
|
|
48
|
+
terminalWidth: stdout?.columns || process.stdout?.columns || 100,
|
|
49
|
+
}),
|
|
50
|
+
[snapshot, stdout?.columns]
|
|
46
51
|
);
|
|
47
52
|
|
|
48
53
|
return createElement(
|
|
@@ -55,28 +60,15 @@ export function AssistantApp({
|
|
|
55
60
|
onRequestClose,
|
|
56
61
|
})
|
|
57
62
|
: null,
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
view.blocks.length === 0
|
|
64
|
+
? createElement(WelcomePanel, { view })
|
|
65
|
+
: createElement(Transcript, { view }),
|
|
60
66
|
createElement(Text, null, ""),
|
|
61
|
-
createElement(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
createElement(Text, null, ""),
|
|
67
|
-
createElement(
|
|
68
|
-
Box,
|
|
69
|
-
{
|
|
70
|
-
borderStyle: "round",
|
|
71
|
-
flexDirection: "column",
|
|
72
|
-
paddingLeft: 1,
|
|
73
|
-
paddingRight: 1,
|
|
74
|
-
},
|
|
75
|
-
createElement(Text, null, dim("Message")),
|
|
76
|
-
renderComposer(snapshot)
|
|
77
|
-
),
|
|
78
|
-
createElement(Text, null, ""),
|
|
79
|
-
createElement(Text, null, dim(buildFooter(snapshot, initialPromptFinished)))
|
|
67
|
+
createElement(ComposerBar, { view, busy: snapshot.busy }),
|
|
68
|
+
createElement(Text, null, dim(view.statusLine)),
|
|
69
|
+
exitAfterInitialPrompt && initialPromptFinished && !snapshot.busy
|
|
70
|
+
? createElement(Text, null, dim("initial prompt complete"))
|
|
71
|
+
: null
|
|
80
72
|
);
|
|
81
73
|
}
|
|
82
74
|
|
|
@@ -88,8 +80,8 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
|
|
|
88
80
|
(onRequestClose || exit)();
|
|
89
81
|
return;
|
|
90
82
|
}
|
|
91
|
-
if (input === "
|
|
92
|
-
(
|
|
83
|
+
if (key.ctrl && input === "l" && !snapshot.busy) {
|
|
84
|
+
assistantState.clearMessages();
|
|
93
85
|
return;
|
|
94
86
|
}
|
|
95
87
|
if (key.return) {
|
|
@@ -130,76 +122,113 @@ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
|
|
|
130
122
|
return null;
|
|
131
123
|
}
|
|
132
124
|
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
125
|
+
function WelcomePanel({ view }) {
|
|
126
|
+
return createElement(
|
|
127
|
+
Box,
|
|
128
|
+
{
|
|
129
|
+
borderStyle: "round",
|
|
130
|
+
flexDirection: "column",
|
|
131
|
+
paddingLeft: 1,
|
|
132
|
+
paddingRight: 1,
|
|
133
|
+
},
|
|
134
|
+
createElement(Text, null, bold(view.title)),
|
|
135
|
+
createElement(Text, null, dim(view.welcome.subtitle)),
|
|
136
|
+
createElement(Text, null, ""),
|
|
137
|
+
...view.welcome.rows.map(([label, value]) => (
|
|
138
|
+
createElement(Text, { key: label }, `${padLabel(label)} ${colorWelcomeValue(label, value)}`)
|
|
139
|
+
)),
|
|
140
|
+
createElement(Text, null, ""),
|
|
141
|
+
createElement(Text, null, bold("Try")),
|
|
142
|
+
...view.welcome.suggestions.map((suggestion) => (
|
|
143
|
+
createElement(Text, { key: suggestion }, ` ${dim("›")} ${suggestion}`)
|
|
144
|
+
))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
142
147
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
148
|
+
function Transcript({ view }) {
|
|
149
|
+
return createElement(
|
|
150
|
+
Box,
|
|
151
|
+
{ flexDirection: "column" },
|
|
152
|
+
createElement(Text, null, bold(view.title)),
|
|
153
|
+
createElement(Text, null, dim(view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "")),
|
|
154
|
+
createElement(Text, null, ""),
|
|
155
|
+
view.notice ? createElement(Text, null, yellow(view.notice)) : null,
|
|
156
|
+
...view.blocks.flatMap((block) => renderBlock(block))
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderBlock(block) {
|
|
161
|
+
const lines = String(block.text || "").split(/\r?\n/);
|
|
162
|
+
const visibleLines = lines.length > MAX_BLOCK_LINES
|
|
163
|
+
? [...lines.slice(0, MAX_BLOCK_LINES - 1), `… ${lines.length - MAX_BLOCK_LINES + 1} more lines omitted`]
|
|
164
|
+
: lines;
|
|
165
|
+
const marker = colorMarker(block);
|
|
166
|
+
const title = block.title ? ` ${bold(block.title)}` : "";
|
|
167
|
+
const first = visibleLines[0] || "";
|
|
168
|
+
const rendered = [
|
|
169
|
+
createElement(Text, { key: `${block.id}-first` }, `${marker}${title}${title && first ? " " : ""}${colorBlockText(block, first)}`),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (let index = 1; index < visibleLines.length; index += 1) {
|
|
173
|
+
rendered.push(createElement(Text, { key: `${block.id}-${index}` }, ` ${visibleLines[index]}`));
|
|
152
174
|
}
|
|
153
|
-
rendered.push(createElement(Text, { key: `${
|
|
175
|
+
rendered.push(createElement(Text, { key: `${block.id}-gap` }, ""));
|
|
154
176
|
return rendered;
|
|
155
177
|
}
|
|
156
178
|
|
|
157
|
-
function
|
|
179
|
+
function ComposerBar({ view, busy }) {
|
|
158
180
|
const { before, current, after, empty } = getComposerRenderParts({
|
|
159
|
-
text:
|
|
160
|
-
cursor:
|
|
181
|
+
text: view.composer.text,
|
|
182
|
+
cursor: view.composer.cursor,
|
|
161
183
|
});
|
|
184
|
+
const prompt = cyan("❯");
|
|
185
|
+
const promptText = empty ? dim(`${view.composer.placeholder} `) : before;
|
|
162
186
|
return createElement(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
Box,
|
|
188
|
+
{
|
|
189
|
+
borderStyle: "single",
|
|
190
|
+
borderLeft: false,
|
|
191
|
+
borderRight: false,
|
|
192
|
+
paddingTop: 0,
|
|
193
|
+
paddingBottom: 0,
|
|
194
|
+
},
|
|
195
|
+
createElement(
|
|
196
|
+
Text,
|
|
197
|
+
null,
|
|
198
|
+
`${prompt} `,
|
|
199
|
+
promptText,
|
|
200
|
+
createElement(Text, { inverse: true }, current),
|
|
201
|
+
after,
|
|
202
|
+
busy ? dim(" provider responding") : ""
|
|
203
|
+
)
|
|
168
204
|
);
|
|
169
205
|
}
|
|
170
206
|
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
const provider = snapshot.provider || "auto";
|
|
174
|
-
const resolvedProvider = snapshot.resolvedProvider && snapshot.resolvedProvider !== provider ? `→${snapshot.resolvedProvider}` : "";
|
|
175
|
-
const model = snapshot.model ? ` · ${snapshot.model}` : "";
|
|
176
|
-
const effort = snapshot.effort ? ` · ${snapshot.effort}` : "";
|
|
177
|
-
const context = snapshot.context?.selection?.filePath || snapshot.context?.selection?.serviceName || "no focus";
|
|
178
|
-
return `testkit assistant · ${provider}${resolvedProvider}${model}${effort} · ${status} · ${context}`;
|
|
207
|
+
function padLabel(label) {
|
|
208
|
+
return `${dim(String(label).padEnd(10, " "))}`;
|
|
179
209
|
}
|
|
180
210
|
|
|
181
|
-
function
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
return "Enter send · arrows/Home/End move cursor · Backspace/Delete edit · /settings · q quit";
|
|
211
|
+
function colorWelcomeValue(label, value) {
|
|
212
|
+
if (label === "Latest" && /^FAILED\b/.test(String(value))) return red(value);
|
|
213
|
+
if (label === "Latest" && /^PASSED\b/.test(String(value))) return green(value);
|
|
214
|
+
if (label === "Issues" && value !== "None detected") return yellow(value);
|
|
215
|
+
if (label === "Provider") return cyan(value);
|
|
216
|
+
return value;
|
|
189
217
|
}
|
|
190
218
|
|
|
191
|
-
function
|
|
192
|
-
if (
|
|
193
|
-
if (
|
|
194
|
-
if (
|
|
195
|
-
|
|
219
|
+
function colorMarker(block) {
|
|
220
|
+
if (block.kind === "user") return cyan(block.marker);
|
|
221
|
+
if (block.kind === "system") return red(block.marker);
|
|
222
|
+
if (block.kind === "tool-running") return yellow(block.marker);
|
|
223
|
+
if (block.kind === "testkit-run") return green(block.marker);
|
|
224
|
+
return block.marker;
|
|
196
225
|
}
|
|
197
226
|
|
|
198
|
-
function
|
|
199
|
-
if (
|
|
200
|
-
if (
|
|
201
|
-
if (
|
|
202
|
-
return
|
|
227
|
+
function colorBlockText(block, text) {
|
|
228
|
+
if (block.kind === "user") return text;
|
|
229
|
+
if (block.kind === "system") return red(text);
|
|
230
|
+
if (block.kind === "tool-running") return yellow(text);
|
|
231
|
+
return text;
|
|
203
232
|
}
|
|
204
233
|
|
|
205
234
|
function isPrintableInput(input, key) {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const TESTKIT_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
2
|
+
const TESTKIT_DIR_COMMANDS = new Set(["run", "discover", "status", "doctor", "destroy", "cleanup", "typecheck"]);
|
|
3
|
+
const PACKAGE_RUNNERS = new Set(["npx", "pnpm", "npm", "yarn", "bun"]);
|
|
4
|
+
|
|
5
|
+
export function extractShellCommand(args = {}) {
|
|
6
|
+
if (!args || typeof args !== "object") return "";
|
|
7
|
+
const value =
|
|
8
|
+
args.command ??
|
|
9
|
+
args.cmd ??
|
|
10
|
+
args.commandString ??
|
|
11
|
+
args.shellCommand ??
|
|
12
|
+
args.input ??
|
|
13
|
+
args.script ??
|
|
14
|
+
"";
|
|
15
|
+
if (Array.isArray(value)) return value.map((part) => shellEscapeArg(part)).join(" ");
|
|
16
|
+
return String(value || "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function planShellCommand(rawCommand) {
|
|
20
|
+
const raw = String(rawCommand || "").trim();
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return {
|
|
23
|
+
executableCommand: "",
|
|
24
|
+
rawCommand: raw,
|
|
25
|
+
displayCommand: raw,
|
|
26
|
+
command: "",
|
|
27
|
+
title: "Shell command",
|
|
28
|
+
testkitRelated: false,
|
|
29
|
+
normalized: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const testkit = planTestkitCommand(raw);
|
|
34
|
+
if (testkit) return testkit;
|
|
35
|
+
|
|
36
|
+
const testkitScript = planTestkitPackageScript(raw);
|
|
37
|
+
if (testkitScript) return testkitScript;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
executableCommand: raw,
|
|
41
|
+
rawCommand: raw,
|
|
42
|
+
displayCommand: raw,
|
|
43
|
+
command: firstCommandToken(raw),
|
|
44
|
+
title: "Shell command",
|
|
45
|
+
testkitRelated: false,
|
|
46
|
+
normalized: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function planTestkitPackageScript(raw) {
|
|
51
|
+
if (containsShellControl(raw)) return null;
|
|
52
|
+
const tokens = tokenizeShellWords(raw);
|
|
53
|
+
if (!tokens || tokens.length < 3) return null;
|
|
54
|
+
if (tokens[0] !== "npm" || tokens[1] !== "run") return null;
|
|
55
|
+
if (tokens[2] !== "testkit" && !tokens[2].startsWith("testkit:")) return null;
|
|
56
|
+
return {
|
|
57
|
+
executableCommand: raw,
|
|
58
|
+
rawCommand: raw,
|
|
59
|
+
displayCommand: raw,
|
|
60
|
+
command: "npm run testkit",
|
|
61
|
+
title: "npm testkit script",
|
|
62
|
+
testkitRelated: true,
|
|
63
|
+
normalized: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function planTestkitCommand(raw) {
|
|
68
|
+
if (containsShellControl(raw)) return null;
|
|
69
|
+
const tokens = tokenizeShellWords(raw);
|
|
70
|
+
if (!tokens || tokens.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
const extracted = extractTestkitInvocation(tokens);
|
|
73
|
+
if (!extracted) return null;
|
|
74
|
+
|
|
75
|
+
const canonicalArgs = canonicalizeTestkitArgs(extracted.args);
|
|
76
|
+
const executableCommand = ["testkit", ...canonicalArgs].map(shellEscapeArg).join(" ");
|
|
77
|
+
const wasNormalized = executableCommand !== raw;
|
|
78
|
+
return {
|
|
79
|
+
executableCommand,
|
|
80
|
+
rawCommand: raw,
|
|
81
|
+
displayCommand: executableCommand,
|
|
82
|
+
command: "testkit",
|
|
83
|
+
title: "testkit command",
|
|
84
|
+
testkitRelated: true,
|
|
85
|
+
normalized: wasNormalized,
|
|
86
|
+
normalizationReason: wasNormalized ? extracted.reason : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractTestkitInvocation(tokens) {
|
|
91
|
+
if (tokens[0] === "testkit") {
|
|
92
|
+
return {
|
|
93
|
+
args: tokens.slice(1),
|
|
94
|
+
reason: "canonicalized local testkit invocation",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!PACKAGE_RUNNERS.has(tokens[0])) return null;
|
|
99
|
+
|
|
100
|
+
if (tokens[0] === "npm" && ["exec", "x"].includes(tokens[1])) {
|
|
101
|
+
const index = findPackageTarget(tokens, 2);
|
|
102
|
+
if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced npm exec testkit with local testkit" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (tokens[0] === "npx") {
|
|
106
|
+
const index = findPackageTarget(tokens, 1);
|
|
107
|
+
if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced npx testkit with local testkit" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (tokens[0] === "pnpm" && ["exec", "dlx"].includes(tokens[1])) {
|
|
111
|
+
const index = findPackageTarget(tokens, 2);
|
|
112
|
+
if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced pnpm testkit launcher with local testkit" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (tokens[0] === "yarn" && tokens[1] === "testkit") {
|
|
116
|
+
return { args: tokens.slice(2), reason: "replaced yarn testkit launcher with local testkit" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (tokens[0] === "bun" && ["x", "run"].includes(tokens[1])) {
|
|
120
|
+
const index = findPackageTarget(tokens, 2);
|
|
121
|
+
if (index >= 0) return { args: tokens.slice(index + 1), reason: "replaced bun testkit launcher with local testkit" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function canonicalizeTestkitArgs(inputArgs) {
|
|
128
|
+
const args = [...inputArgs];
|
|
129
|
+
if (args.length === 0) return [];
|
|
130
|
+
|
|
131
|
+
if (TESTKIT_TYPES.has(args[0])) {
|
|
132
|
+
return withDir(["run", "--type", args[0], ...args.slice(1)]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!TESTKIT_DIR_COMMANDS.has(args[0])) {
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (args[0] === "run") {
|
|
140
|
+
const runArgs = [...args];
|
|
141
|
+
if (TESTKIT_TYPES.has(runArgs[1])) {
|
|
142
|
+
const type = runArgs.splice(1, 1)[0];
|
|
143
|
+
if (!hasFlag(runArgs, "--type", "-t")) runArgs.splice(1, 0, "--type", type);
|
|
144
|
+
}
|
|
145
|
+
return withDir(runArgs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return withDir(args);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function withDir(args) {
|
|
152
|
+
if (hasFlag(args, "--dir", "-d") || args.includes("--help") || args.includes("-h")) return args;
|
|
153
|
+
const [command, ...rest] = args;
|
|
154
|
+
return [command, "--dir", ".", ...rest];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasFlag(args, longFlag, shortFlag) {
|
|
158
|
+
return args.some((arg) => arg === longFlag || arg.startsWith(`${longFlag}=`) || arg === shortFlag);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findPackageTarget(tokens, startIndex) {
|
|
162
|
+
for (let index = startIndex; index < tokens.length; index += 1) {
|
|
163
|
+
const token = tokens[index];
|
|
164
|
+
if (token === "--") continue;
|
|
165
|
+
if (token === "testkit" || token === "@elench/testkit") return index;
|
|
166
|
+
if (!token.startsWith("-")) return -1;
|
|
167
|
+
}
|
|
168
|
+
return -1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function firstCommandToken(command) {
|
|
172
|
+
const tokens = tokenizeShellWords(command);
|
|
173
|
+
return tokens?.[0] || command.split(/\s+/)[0] || "command";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function containsShellControl(command) {
|
|
177
|
+
return /[\n;&|<>`]/.test(command);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function tokenizeShellWords(command) {
|
|
181
|
+
const words = [];
|
|
182
|
+
let current = "";
|
|
183
|
+
let quote = null;
|
|
184
|
+
let escaping = false;
|
|
185
|
+
|
|
186
|
+
for (const char of String(command)) {
|
|
187
|
+
if (escaping) {
|
|
188
|
+
current += char;
|
|
189
|
+
escaping = false;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (char === "\\") {
|
|
193
|
+
escaping = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (quote) {
|
|
197
|
+
if (char === quote) {
|
|
198
|
+
quote = null;
|
|
199
|
+
} else {
|
|
200
|
+
current += char;
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (char === "'" || char === '"') {
|
|
205
|
+
quote = char;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (/\s/.test(char)) {
|
|
209
|
+
if (current) {
|
|
210
|
+
words.push(current);
|
|
211
|
+
current = "";
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
current += char;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (escaping || quote) return null;
|
|
219
|
+
if (current) words.push(current);
|
|
220
|
+
return words;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shellEscapeArg(value) {
|
|
224
|
+
const stringValue = String(value);
|
|
225
|
+
if (/^[a-zA-Z0-9._:@/%+=,-]+$/.test(stringValue)) return stringValue;
|
|
226
|
+
return `'${stringValue.replace(/'/g, `'\\''`)}'`;
|
|
227
|
+
}
|
|
@@ -160,7 +160,9 @@ function buildContextMarkdown(productDir, snapshot, paths) {
|
|
|
160
160
|
lines.push(
|
|
161
161
|
"",
|
|
162
162
|
"## Guidance",
|
|
163
|
-
"- Use
|
|
163
|
+
"- Use the local `testkit` command directly when you need to execute or inspect tests.",
|
|
164
|
+
"- Preferred commands: `testkit run --dir . --type <type>`, `testkit discover --dir .`, `testkit status --dir .`, and `testkit doctor --dir .`.",
|
|
165
|
+
"- Do not launch testkit through pnpm, npm, yarn, bun, or npx unless the user explicitly asks for that exact package-manager command.",
|
|
164
166
|
"- Use the command log and focused context files before rereading artifacts manually.",
|
|
165
167
|
"- Prefer repo-local commands over guessing project-specific wrappers.",
|
|
166
168
|
""
|
|
@@ -180,8 +182,6 @@ function buildCommandsMarkdown() {
|
|
|
180
182
|
"- `testkit status --dir .`",
|
|
181
183
|
"- `testkit doctor --dir .`",
|
|
182
184
|
"- `testkit destroy --dir .`",
|
|
183
|
-
"- `npm run testkit`",
|
|
184
|
-
"- `npx testkit --dir . --type e2e`",
|
|
185
185
|
"",
|
|
186
186
|
].join("\n");
|
|
187
187
|
}
|