@elench/testkit 0.1.96 → 0.1.97

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.
@@ -6,6 +6,11 @@ import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
6
6
  import { executeAssistantTool } from "./tool-registry.mjs";
7
7
  import { runAssistantConversationTurn } from "./session.mjs";
8
8
  import { prepareAssistantContextPack } from "./context-pack.mjs";
9
+ import {
10
+ discoverAssistantModels,
11
+ formatModelChoices,
12
+ getModelProviderMismatch,
13
+ } from "./model-discovery.mjs";
9
14
  import {
10
15
  DEFAULT_ASSISTANT_SETTINGS,
11
16
  loadAssistantSettings,
@@ -57,7 +62,15 @@ export function createAssistantState({
57
62
  }
58
63
  );
59
64
  let resolvedProviderName = resolveInitialProvider(settings.provider, env);
65
+ const sanitizedStartup = sanitizeSettingsForResolvedProvider({
66
+ productDir,
67
+ settings,
68
+ resolvedProvider: resolvedProviderName,
69
+ });
70
+ settings = sanitizedStartup.settings;
71
+ if (sanitizedStartup.notice) notice = sanitizedStartup.notice;
60
72
  let activeStatus = null;
73
+ let startupNoticeEmitted = false;
61
74
  let contextUsage = buildContextUsage({
62
75
  provider: resolvedProviderName || settings.provider,
63
76
  model: settings.model,
@@ -176,12 +189,20 @@ export function createAssistantState({
176
189
 
177
190
  setProvider(nextProvider) {
178
191
  settings = mergeAssistantSettings(settings, { provider: nextProvider || DEFAULT_ASSISTANT_SETTINGS.provider });
179
- resolvedProviderName = null;
192
+ resolvedProviderName = resolveInitialProvider(settings.provider, env);
193
+ if (settings.model && getModelProviderMismatch(resolvedProviderName, settings.model)) {
194
+ settings = mergeAssistantSettings(settings, { model: null });
195
+ }
180
196
  saveAssistantSettings(productDir, settings);
181
197
  notify();
182
198
  },
183
199
 
184
- setModel(nextModel) {
200
+ setModel(nextModel, { custom = false } = {}) {
201
+ const resolvedProvider = resolveInitialProvider(settings.provider, env);
202
+ const mismatch = getModelProviderMismatch(resolvedProvider, nextModel);
203
+ if (mismatch && !custom) {
204
+ throw new Error(mismatch);
205
+ }
185
206
  settings = mergeAssistantSettings(settings, { model: nextModel || null });
186
207
  saveAssistantSettings(productDir, settings);
187
208
  notify();
@@ -229,6 +250,10 @@ export function createAssistantState({
229
250
  async submitInput(input) {
230
251
  const trimmed = String(input || "").trim();
231
252
  if (!trimmed) return;
253
+ if (notice && !startupNoticeEmitted) {
254
+ startupNoticeEmitted = true;
255
+ appendMessage({ role: "system", text: notice });
256
+ }
232
257
  appendMessage({ role: "user", text: trimmed });
233
258
 
234
259
  const slash = parseSlashCommandSafe(trimmed);
@@ -258,6 +283,29 @@ export function createAssistantState({
258
283
  return;
259
284
  }
260
285
 
286
+ const routedSlash = routeLocalIntent(trimmed);
287
+ if (routedSlash) {
288
+ try {
289
+ await executeSlashCommand({
290
+ slash: routedSlash,
291
+ state,
292
+ productDir,
293
+ settings,
294
+ configs,
295
+ env,
296
+ appendMessage,
297
+ });
298
+ } catch (error) {
299
+ appendMessage({
300
+ role: "system",
301
+ text: error instanceof Error ? error.message : String(error),
302
+ });
303
+ }
304
+ refreshContextPack();
305
+ notify();
306
+ return;
307
+ }
308
+
261
309
  try {
262
310
  setBusy(true, `Thinking with ${settings.provider === "auto" ? "provider" : settings.provider}...`);
263
311
  const emitted = await runAssistantConversationTurn({
@@ -372,10 +420,17 @@ async function executeSlashCommand({
372
420
  return;
373
421
  }
374
422
  if (slash.type === "model") {
375
- state.setModel(slash.model);
423
+ state.setModel(slash.model, { custom: slash.custom });
376
424
  appendMessage({ role: "assistant", text: slash.model ? `Model set to ${slash.model}.` : "Model reset to provider default." });
377
425
  return;
378
426
  }
427
+ if (slash.type === "model-list") {
428
+ const snapshot = state.getSnapshot();
429
+ const provider = snapshot.resolvedProvider || resolveInitialProvider(snapshot.provider, env) || snapshot.provider;
430
+ const discovery = await discoverAssistantModels({ provider, productDir, env });
431
+ appendMessage({ role: "assistant", text: formatModelChoices(discovery, { currentModel: snapshot.model }) });
432
+ return;
433
+ }
379
434
  if (slash.type === "effort") {
380
435
  state.setEffort(slash.effort);
381
436
  appendMessage({ role: "assistant", text: slash.effort ? `Effort set to ${slash.effort}.` : "Effort reset to provider default." });
@@ -496,9 +551,16 @@ async function executeSlashTool(slash, context) {
496
551
  }
497
552
 
498
553
  function buildRunSlashCommand(options = {}) {
499
- const parts = ["testkit", "run", "--dir", "."];
500
- for (const type of options.type || []) {
501
- parts.push("--type", type);
554
+ const types = options.type || [];
555
+ const parts = ["testkit", "run"];
556
+ if (types.length === 1) {
557
+ parts.push(types[0]);
558
+ }
559
+ parts.push("--dir", ".");
560
+ if (types.length !== 1) {
561
+ for (const type of types) {
562
+ parts.push("--type", type);
563
+ }
502
564
  }
503
565
  for (const suite of options.suite || []) {
504
566
  parts.push("--suite", suite);
@@ -526,3 +588,34 @@ function parseSlashCommandSafe(input) {
526
588
  };
527
589
  }
528
590
  }
591
+
592
+ function sanitizeSettingsForResolvedProvider({ productDir, settings, resolvedProvider }) {
593
+ const mismatch = getModelProviderMismatch(resolvedProvider, settings.model);
594
+ if (!mismatch) return { settings, notice: null };
595
+ const previousModel = settings.model;
596
+ const sanitized = mergeAssistantSettings(settings, { model: null });
597
+ saveAssistantSettings(productDir, sanitized);
598
+ return {
599
+ settings: sanitized,
600
+ notice: `Cleared incompatible saved model "${previousModel}" for ${resolvedProvider}.`,
601
+ };
602
+ }
603
+
604
+ function routeLocalIntent(input) {
605
+ const normalized = String(input || "").trim().toLowerCase();
606
+ const runMatch = normalized.match(/^run\s+(int|e2e|scenario|dal|load|pw|all)(?:\s+tests?)?$/);
607
+ if (runMatch) {
608
+ return {
609
+ type: "run",
610
+ options: {
611
+ type: [runMatch[1]],
612
+ suite: [],
613
+ file: [],
614
+ service: null,
615
+ },
616
+ };
617
+ }
618
+ if (/^(show\s+)?latest\s+summary$/.test(normalized)) return { type: "status" };
619
+ if (/^list\s+test\s+files$/.test(normalized)) return { type: "discover" };
620
+ return null;
621
+ }
@@ -5,7 +5,6 @@ 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";
9
8
 
10
9
  const COMMAND_OUTPUT_LIMIT = 14_000;
11
10
  const COMMAND_LINE_LIMIT = 220;
@@ -15,7 +14,7 @@ export function listAssistantTools() {
15
14
  return [
16
15
  {
17
16
  name: "shell_exec",
18
- description: "Execute a shell command inside the repository. Use local testkit commands for testkit work.",
17
+ description: "Execute a shell command inside the repository. Prefer real repo commands such as npm, npx, and testkit.",
19
18
  },
20
19
  {
21
20
  name: "read_context",
@@ -49,10 +48,10 @@ export async function executeAssistantTool(name, argumentsObject, context) {
49
48
  }
50
49
 
51
50
  async function shellExecTool(args, context) {
52
- const command = extractShellCommand(args).trim();
51
+ const command = String(args.command || "").trim();
53
52
  if (!command) throw new Error("shell_exec requires a command string");
54
53
 
55
- const shellCommand = planShellCommand(command);
54
+ const shellCommand = classifyShellCommand(command);
56
55
  const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
57
56
  context.commandLog?.appendCommandLog({
58
57
  type: "command_start",
@@ -60,22 +59,17 @@ async function shellExecTool(args, context) {
60
59
  commandId,
61
60
  cwd: context.productDir,
62
61
  raw: command,
63
- executable: shellCommand.executableCommand,
64
- normalized: shellCommand.normalized,
65
62
  });
66
63
  context.onEvent?.({
67
64
  type: "tool-start",
68
65
  tool: "shell_exec",
69
- command: shellCommand.executableCommand,
70
- rawCommand: command,
66
+ command,
71
67
  title: shellCommand.title,
72
68
  testkitRelated: shellCommand.testkitRelated,
73
- message: shellCommand.normalized
74
- ? `Running ${shellCommand.displayCommand} (${shellCommand.normalizationReason})`
75
- : `Running ${shellCommand.displayCommand}`,
69
+ message: `Running ${shellCommand.display}`,
76
70
  });
77
71
 
78
- const result = await execaCommand(shellCommand.executableCommand, {
72
+ const result = await execaCommand(command, {
79
73
  cwd: context.productDir,
80
74
  reject: false,
81
75
  shell: true,
@@ -93,21 +87,18 @@ async function shellExecTool(args, context) {
93
87
  commandId,
94
88
  cwd: context.productDir,
95
89
  raw: command,
96
- executable: shellCommand.executableCommand,
97
- normalized: shellCommand.normalized,
98
90
  code: result.exitCode ?? 0,
99
91
  signal: result.signal ?? null,
100
92
  });
101
93
  context.onEvent?.({
102
94
  type: "tool-exit",
103
95
  tool: "shell_exec",
104
- command: shellCommand.executableCommand,
105
- rawCommand: command,
96
+ command,
106
97
  title: shellCommand.title,
107
98
  testkitRelated: shellCommand.testkitRelated,
108
99
  code: result.exitCode ?? 0,
109
100
  signal: result.signal ?? null,
110
- message: `${shellCommand.displayCommand} exited ${result.exitCode ?? 0}`,
101
+ message: `${shellCommand.display} exited ${result.exitCode ?? 0}`,
111
102
  });
112
103
 
113
104
  if (shellCommand.testkitRelated) {
@@ -115,15 +106,13 @@ async function shellExecTool(args, context) {
115
106
  }
116
107
  context.commandLog?.refresh?.();
117
108
 
118
- const lines = formatCommandResult(result, shellCommand);
109
+ const lines = formatCommandResult(command, result, shellCommand);
119
110
  return {
120
111
  ok: (result.exitCode ?? 0) === 0,
121
112
  title: shellCommand.title,
122
113
  text: lines.join("\n"),
123
114
  data: {
124
115
  command,
125
- executableCommand: shellCommand.executableCommand,
126
- normalizedCommand: shellCommand.normalized,
127
116
  stdout: result.stdout || "",
128
117
  stderr: result.stderr || "",
129
118
  exitCode: result.exitCode ?? 0,
@@ -227,11 +216,42 @@ async function searchRepoTool(args, context) {
227
216
  };
228
217
  }
229
218
 
230
- function formatCommandResult(result, shellCommand) {
231
- const lines = [`$ ${shellCommand.displayCommand}`];
232
- if (shellCommand.normalized) {
233
- lines.push(`normalized from: ${shellCommand.rawCommand}`);
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
+ };
234
228
  }
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}`];
235
255
  const stdout = (result.stdout || "").trim();
236
256
  const stderr = (result.stderr || "").trim();
237
257
  const merged = [];
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { execa } from "execa";
4
- import { buildExecutionEnv } from "../runner/template.mjs";
4
+ import { buildTemplateExecutionEnv } from "../runner/template.mjs";
5
5
  import {
6
6
  collectConfiguredInputs,
7
7
  runConfiguredSteps,
@@ -13,7 +13,7 @@ export async function runTemplateStage(config, stageName, databaseUrl, options =
13
13
  if (steps.length === 0) return;
14
14
 
15
15
  const env = {
16
- ...buildExecutionEnv(config, {}, process.env),
16
+ ...buildTemplateExecutionEnv(config, {}, process.env),
17
17
  DATABASE_URL: databaseUrl,
18
18
  };
19
19
 
@@ -54,7 +54,7 @@ export async function captureTemplateSnapshot(config, outputPath, databaseUrl, o
54
54
  {
55
55
  cwd: config.productDir,
56
56
  env: {
57
- ...buildExecutionEnv(config, {}, process.env),
57
+ ...buildTemplateExecutionEnv(config, {}, process.env),
58
58
  DATABASE_URL: templateDbUrl,
59
59
  },
60
60
  stdout: "pipe",
@@ -127,6 +127,7 @@ export async function computeRuntimePrepareFingerprint(config) {
127
127
  : null,
128
128
  })
129
129
  );
130
+ hash.update(JSON.stringify(collectRuntimeDatabaseFingerprintInputs(config)));
130
131
 
131
132
  for (const envFile of config.testkit.envFiles || []) {
132
133
  appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
@@ -138,6 +139,41 @@ export async function computeRuntimePrepareFingerprint(config) {
138
139
  return hash.digest("hex");
139
140
  }
140
141
 
142
+ function collectRuntimeDatabaseFingerprintInputs(config) {
143
+ const inputs = [];
144
+ const ownDatabaseUrl = readDatabaseUrl(config.stateDir);
145
+ if (ownDatabaseUrl) {
146
+ inputs.push({ service: config.name, url: ownDatabaseUrl });
147
+ }
148
+
149
+ const referencedServices = new Set();
150
+ for (const value of Object.values(config.testkit.serviceEnv || {})) {
151
+ collectDatabasePlaceholderServices(value, referencedServices, config.name);
152
+ }
153
+ for (const value of Object.values(config.testkit.local?.env || {})) {
154
+ collectDatabasePlaceholderServices(value, referencedServices, config.name);
155
+ }
156
+
157
+ const stateDirByService = config.testkit.templateContext?.stateDirByService;
158
+ for (const serviceName of [...referencedServices].sort()) {
159
+ const stateDir = stateDirByService?.get?.(serviceName);
160
+ const databaseUrl = stateDir ? readDatabaseUrl(stateDir) : null;
161
+ inputs.push({ service: serviceName, url: databaseUrl || null });
162
+ }
163
+
164
+ return inputs;
165
+ }
166
+
167
+ function collectDatabasePlaceholderServices(value, out, defaultServiceName) {
168
+ if (typeof value !== "string") return;
169
+ const matcher = /\{db(?:Url|Host|Port|Name|User|Password)(?::([a-zA-Z0-9_-]+))?\}/g;
170
+ let match = matcher.exec(value);
171
+ while (match) {
172
+ out.add(match[1] || defaultServiceName);
173
+ match = matcher.exec(value);
174
+ }
175
+ }
176
+
141
177
  function appendResolvedInputToHash(hash, productDir, absPath) {
142
178
  const relative = path.relative(productDir, absPath);
143
179
  appendInputToHash(hash, productDir, relative);
@@ -204,17 +204,29 @@ export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.en
204
204
  return buildExecutionEnvWithContext(config, null, extraEnv, processEnv);
205
205
  }
206
206
 
207
+ export function buildTemplateExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
208
+ return buildExecutionEnvWithContext(config, null, extraEnv, processEnv, {
209
+ omitRuntimeDatabaseBindings: true,
210
+ });
211
+ }
212
+
207
213
  export function buildTaskExecutionEnv(config, lease, extraEnv = {}, processEnv = process.env) {
208
214
  return buildExecutionEnvWithContext(config, lease, extraEnv, processEnv);
209
215
  }
210
216
 
211
- function buildExecutionEnvWithContext(config, lease, extraEnv, processEnv) {
217
+ function buildExecutionEnvWithContext(config, lease, extraEnv, processEnv, options = {}) {
212
218
  const inheritedEnv = { ...processEnv };
213
219
  const templateContext = buildTemplateContext(config, lease);
220
+ const serviceEnv = options.omitRuntimeDatabaseBindings
221
+ ? omitRuntimeDatabaseBindings(config.testkit.serviceEnv || {})
222
+ : config.testkit.serviceEnv || {};
223
+ const localEnv = options.omitRuntimeDatabaseBindings
224
+ ? omitRuntimeDatabaseBindings(config.testkit.local?.env || {})
225
+ : config.testkit.local?.env || {};
214
226
  const env = {
215
227
  ...inheritedEnv,
216
- ...resolveEnvTemplates(config.testkit.serviceEnv || {}, templateContext),
217
- ...resolveEnvTemplates(config.testkit.local?.env || {}, templateContext),
228
+ ...resolveEnvTemplates(serviceEnv, templateContext),
229
+ ...resolveEnvTemplates(localEnv, templateContext),
218
230
  ...resolveEnvTemplates(extraEnv, templateContext),
219
231
  TESTKIT_ACTIVE: "1",
220
232
  ...(config.runtimeId ? { TESTKIT_RUNTIME_ID: String(config.runtimeId) } : {}),
@@ -340,6 +352,15 @@ function resolveEnvTemplates(values, templateContext) {
340
352
  );
341
353
  }
342
354
 
355
+ function omitRuntimeDatabaseBindings(values = {}) {
356
+ return Object.fromEntries(
357
+ Object.entries(values).filter(([_key, value]) => {
358
+ if (typeof value !== "string") return true;
359
+ return !/\{db(?:Url|Host|Port|Name|User|Password)(?::[a-zA-Z0-9_-]+)?\}/.test(value);
360
+ })
361
+ );
362
+ }
363
+
343
364
  function finalizeRuntimePrepare(prepare, context) {
344
365
  if (!prepare) {
345
366
  return {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/next-analysis",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
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.96",
3
+ "version": "0.1.97",
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.96"
25
+ "@elench/testkit-protocol": "0.1.97"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
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.96",
3
+ "version": "0.1.97",
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.96",
3
+ "version": "0.1.97",
4
4
  "description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -62,7 +62,8 @@
62
62
  "test:audit": "node scripts/test-boundary-audit.mjs",
63
63
  "test:unit": "npm run build:packages && npm run test:audit && vitest run --config vitest.unit.config.mjs",
64
64
  "test:integration": "npm run build:packages && vitest run test/integration",
65
- "test:system": "npm run build:packages && vitest run test/system --passWithNoTests"
65
+ "test:system": "npm run build:packages && vitest run test/system --passWithNoTests",
66
+ "test:live-providers": "npm run build:packages && vitest run --config vitest.live.config.mjs --passWithNoTests"
66
67
  },
67
68
  "files": [
68
69
  "bin/",
@@ -83,10 +84,10 @@
83
84
  },
84
85
  "dependencies": {
85
86
  "@babel/code-frame": "^7.29.0",
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",
87
+ "@elench/next-analysis": "0.1.97",
88
+ "@elench/testkit-bridge": "0.1.97",
89
+ "@elench/testkit-protocol": "0.1.97",
90
+ "@elench/ts-analysis": "0.1.97",
90
91
  "@oclif/core": "^4.10.6",
91
92
  "esbuild": "^0.25.11",
92
93
  "execa": "^9.5.0",