@elench/testkit 0.1.94 → 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.
@@ -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 shell commands like `npm run testkit`, `npx testkit`, or `testkit run --dir . --type <type>` when you need to execute tests.",
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
  }
@@ -16,8 +16,11 @@ export function buildAssistantPrompt({
16
16
  "You are Testkit Assistant.",
17
17
  "You help users run tests, inspect failures, read logs and artifacts, and navigate the current local test state.",
18
18
  "All user natural-language requests must be handled through your own reasoning plus the available tools.",
19
- "Prefer real repository commands through shell_exec when the user asks to run tests or inspect the working repo.",
19
+ "Use shell_exec when the user asks to run tests or inspect the working repo.",
20
+ "For testkit work, invoke the local `testkit` command directly, for example `testkit run --dir . --type e2e` or `testkit discover --dir .`.",
21
+ "Do not wrap testkit with pnpm, npm, yarn, bun, or npx unless the user explicitly asks for that exact package-manager command.",
20
22
  "Use read_context before repeating artifact/log inspection work, and use read_file/search_repo when you need codebase context.",
23
+ "After a tool result, describe only what the tool result actually says. Do not invent filesystem, sandbox, package-manager, or permission errors.",
21
24
  buildAssistantResponseContract({ tools }),
22
25
  "",
23
26
  "Current run summary:",
@@ -5,6 +5,7 @@ import { loadCurrentRunArtifact, loadLatestRunArtifact, resolveFileSubject } fro
5
5
  import {
6
6
  readContextContent,
7
7
  } from "../context-resources.mjs";
8
+ import { extractShellCommand, planShellCommand } from "./command-plan.mjs";
8
9
 
9
10
  const COMMAND_OUTPUT_LIMIT = 14_000;
10
11
  const COMMAND_LINE_LIMIT = 220;
@@ -14,7 +15,7 @@ export function listAssistantTools() {
14
15
  return [
15
16
  {
16
17
  name: "shell_exec",
17
- description: "Execute a shell command inside the repository. Prefer real repo commands such as npm, npx, and testkit.",
18
+ description: "Execute a shell command inside the repository. Use local testkit commands for testkit work.",
18
19
  },
19
20
  {
20
21
  name: "read_context",
@@ -48,10 +49,10 @@ export async function executeAssistantTool(name, argumentsObject, context) {
48
49
  }
49
50
 
50
51
  async function shellExecTool(args, context) {
51
- const command = String(args.command || "").trim();
52
+ const command = extractShellCommand(args).trim();
52
53
  if (!command) throw new Error("shell_exec requires a command string");
53
54
 
54
- const shellCommand = classifyShellCommand(command);
55
+ const shellCommand = planShellCommand(command);
55
56
  const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
56
57
  context.commandLog?.appendCommandLog({
57
58
  type: "command_start",
@@ -59,17 +60,22 @@ async function shellExecTool(args, context) {
59
60
  commandId,
60
61
  cwd: context.productDir,
61
62
  raw: command,
63
+ executable: shellCommand.executableCommand,
64
+ normalized: shellCommand.normalized,
62
65
  });
63
66
  context.onEvent?.({
64
67
  type: "tool-start",
65
68
  tool: "shell_exec",
66
- command,
69
+ command: shellCommand.executableCommand,
70
+ rawCommand: command,
67
71
  title: shellCommand.title,
68
72
  testkitRelated: shellCommand.testkitRelated,
69
- message: `Running ${shellCommand.display}`,
73
+ message: shellCommand.normalized
74
+ ? `Running ${shellCommand.displayCommand} (${shellCommand.normalizationReason})`
75
+ : `Running ${shellCommand.displayCommand}`,
70
76
  });
71
77
 
72
- const result = await execaCommand(command, {
78
+ const result = await execaCommand(shellCommand.executableCommand, {
73
79
  cwd: context.productDir,
74
80
  reject: false,
75
81
  shell: true,
@@ -87,18 +93,21 @@ async function shellExecTool(args, context) {
87
93
  commandId,
88
94
  cwd: context.productDir,
89
95
  raw: command,
96
+ executable: shellCommand.executableCommand,
97
+ normalized: shellCommand.normalized,
90
98
  code: result.exitCode ?? 0,
91
99
  signal: result.signal ?? null,
92
100
  });
93
101
  context.onEvent?.({
94
102
  type: "tool-exit",
95
103
  tool: "shell_exec",
96
- command,
104
+ command: shellCommand.executableCommand,
105
+ rawCommand: command,
97
106
  title: shellCommand.title,
98
107
  testkitRelated: shellCommand.testkitRelated,
99
108
  code: result.exitCode ?? 0,
100
109
  signal: result.signal ?? null,
101
- message: `${shellCommand.display} exited ${result.exitCode ?? 0}`,
110
+ message: `${shellCommand.displayCommand} exited ${result.exitCode ?? 0}`,
102
111
  });
103
112
 
104
113
  if (shellCommand.testkitRelated) {
@@ -106,13 +115,15 @@ async function shellExecTool(args, context) {
106
115
  }
107
116
  context.commandLog?.refresh?.();
108
117
 
109
- const lines = formatCommandResult(command, result, shellCommand);
118
+ const lines = formatCommandResult(result, shellCommand);
110
119
  return {
111
120
  ok: (result.exitCode ?? 0) === 0,
112
121
  title: shellCommand.title,
113
122
  text: lines.join("\n"),
114
123
  data: {
115
124
  command,
125
+ executableCommand: shellCommand.executableCommand,
126
+ normalizedCommand: shellCommand.normalized,
116
127
  stdout: result.stdout || "",
117
128
  stderr: result.stderr || "",
118
129
  exitCode: result.exitCode ?? 0,
@@ -216,42 +227,11 @@ async function searchRepoTool(args, context) {
216
227
  };
217
228
  }
218
229
 
219
- function classifyShellCommand(command) {
220
- const normalized = command.trim();
221
- if (/^(testkit)\b/.test(normalized)) {
222
- return {
223
- command: "testkit",
224
- display: normalized,
225
- title: "testkit command",
226
- testkitRelated: true,
227
- };
230
+ function formatCommandResult(result, shellCommand) {
231
+ const lines = [`$ ${shellCommand.displayCommand}`];
232
+ if (shellCommand.normalized) {
233
+ lines.push(`normalized from: ${shellCommand.rawCommand}`);
228
234
  }
229
- if (/^(npx)\s+testkit\b/.test(normalized)) {
230
- return {
231
- command: "npx testkit",
232
- display: normalized,
233
- title: "npx testkit",
234
- testkitRelated: true,
235
- };
236
- }
237
- if (/^(npm)\s+run\s+testkit\b/.test(normalized) || /^(npm)\s+run\s+testkit:/.test(normalized)) {
238
- return {
239
- command: "npm run testkit",
240
- display: normalized,
241
- title: "npm testkit script",
242
- testkitRelated: true,
243
- };
244
- }
245
- return {
246
- command: normalized.split(/\s+/)[0] || "command",
247
- display: normalized,
248
- title: "Shell command",
249
- testkitRelated: false,
250
- };
251
- }
252
-
253
- function formatCommandResult(command, result, shellCommand) {
254
- const lines = [`$ ${command}`];
255
235
  const stdout = (result.stdout || "").trim();
256
236
  const stderr = (result.stderr || "").trim();
257
237
  const merged = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
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.94",
3
+ "version": "0.1.96",
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.94"
25
+ "@elench/testkit-protocol": "0.1.96"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
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.94",
3
+ "version": "0.1.96",
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.94",
3
+ "version": "0.1.96",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -83,10 +83,10 @@
83
83
  },
84
84
  "dependencies": {
85
85
  "@babel/code-frame": "^7.29.0",
86
- "@elench/next-analysis": "0.1.94",
87
- "@elench/testkit-bridge": "0.1.94",
88
- "@elench/testkit-protocol": "0.1.94",
89
- "@elench/ts-analysis": "0.1.94",
86
+ "@elench/next-analysis": "0.1.96",
87
+ "@elench/testkit-bridge": "0.1.96",
88
+ "@elench/testkit-protocol": "0.1.96",
89
+ "@elench/ts-analysis": "0.1.96",
90
90
  "@oclif/core": "^4.10.6",
91
91
  "esbuild": "^0.25.11",
92
92
  "execa": "^9.5.0",