@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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
2
|
+
|
|
3
|
+
const MODEL_WINDOWS = [
|
|
4
|
+
[/claude.*opus.*4\.7/i, 1_000_000],
|
|
5
|
+
[/claude.*sonnet.*4/i, 200_000],
|
|
6
|
+
[/claude.*haiku.*4/i, 200_000],
|
|
7
|
+
[/claude/i, 200_000],
|
|
8
|
+
[/gpt-5\.5/i, 400_000],
|
|
9
|
+
[/gpt-5\.4/i, 400_000],
|
|
10
|
+
[/gpt-5\.3/i, 400_000],
|
|
11
|
+
[/gpt-5\.2/i, 400_000],
|
|
12
|
+
[/gpt-5\b/i, 400_000],
|
|
13
|
+
[/codex/i, 400_000],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function resolveContextWindow({ provider, model } = {}) {
|
|
17
|
+
const label = [provider, model].filter(Boolean).join(" ");
|
|
18
|
+
for (const [pattern, tokens] of MODEL_WINDOWS) {
|
|
19
|
+
if (pattern.test(label)) return tokens;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function estimateTokenCount(text) {
|
|
25
|
+
const value = String(text || "");
|
|
26
|
+
if (!value) return 0;
|
|
27
|
+
return Math.max(1, Math.ceil(value.length / DEFAULT_CHARS_PER_TOKEN));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildContextUsage({
|
|
31
|
+
provider,
|
|
32
|
+
model,
|
|
33
|
+
prompt,
|
|
34
|
+
exactUsedTokens = null,
|
|
35
|
+
exactMaxTokens = null,
|
|
36
|
+
} = {}) {
|
|
37
|
+
const maxTokens = normalizePositiveInteger(exactMaxTokens) || resolveContextWindow({ provider, model });
|
|
38
|
+
const usedTokens = normalizePositiveInteger(exactUsedTokens) || estimateTokenCount(prompt);
|
|
39
|
+
if (!maxTokens || !usedTokens) {
|
|
40
|
+
return {
|
|
41
|
+
known: false,
|
|
42
|
+
estimated: true,
|
|
43
|
+
usedTokens: usedTokens || null,
|
|
44
|
+
maxTokens: maxTokens || null,
|
|
45
|
+
remainingPercent: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clampedUsed = Math.min(usedTokens, maxTokens);
|
|
50
|
+
return {
|
|
51
|
+
known: true,
|
|
52
|
+
estimated: !exactUsedTokens,
|
|
53
|
+
usedTokens: clampedUsed,
|
|
54
|
+
maxTokens,
|
|
55
|
+
remainingPercent: Math.max(0, Math.floor(((maxTokens - clampedUsed) / maxTokens) * 100)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatContextRemaining(usage) {
|
|
60
|
+
if (!usage?.known || usage.remainingPercent == null) return "[context unknown]";
|
|
61
|
+
const prefix = usage.estimated ? "~" : "";
|
|
62
|
+
return `[${prefix}${usage.remainingPercent}% remaining]`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizePositiveInteger(value) {
|
|
66
|
+
const number = Number(value);
|
|
67
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
68
|
+
return Math.floor(number);
|
|
69
|
+
}
|
|
@@ -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
|
-
"
|
|
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:",
|
|
@@ -16,6 +16,7 @@ export async function runAssistantConversationTurn({
|
|
|
16
16
|
onStatus,
|
|
17
17
|
onToolEvent,
|
|
18
18
|
onResolvedProvider,
|
|
19
|
+
onPrompt,
|
|
19
20
|
} = {}) {
|
|
20
21
|
const tools = listAssistantTools();
|
|
21
22
|
const toolContext = {
|
|
@@ -43,6 +44,12 @@ export async function runAssistantConversationTurn({
|
|
|
43
44
|
const runtimeSettings = settings || { provider };
|
|
44
45
|
const resolvedProvider = resolvePreferredProvider(runtimeSettings.provider || provider, env);
|
|
45
46
|
onResolvedProvider?.(resolvedProvider);
|
|
47
|
+
onPrompt?.({
|
|
48
|
+
prompt,
|
|
49
|
+
provider: resolvedProvider,
|
|
50
|
+
model: runtimeSettings.model || null,
|
|
51
|
+
effort: runtimeSettings.effort || null,
|
|
52
|
+
});
|
|
46
53
|
onStatus?.(`Thinking with ${resolvedProvider}...`);
|
|
47
54
|
const events = [];
|
|
48
55
|
const session = startAgentSession({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadCurrentRunArtifact, loadLatestRunArtifact } from "../viewer.mjs";
|
|
2
2
|
import { createInspectState } from "../tui/inspect-state.mjs";
|
|
3
3
|
import { buildContextSelection } from "../context-resources.mjs";
|
|
4
|
+
import { isProviderInstalled } from "../agents/index.mjs";
|
|
4
5
|
import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
|
|
5
6
|
import { executeAssistantTool } from "./tool-registry.mjs";
|
|
6
7
|
import { runAssistantConversationTurn } from "./session.mjs";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
moveComposerCursorToStart as moveComposerCursorStateToStart,
|
|
23
24
|
setComposerText,
|
|
24
25
|
} from "./composer.mjs";
|
|
26
|
+
import { buildContextUsage } from "./context-window.mjs";
|
|
25
27
|
|
|
26
28
|
export function createAssistantState({
|
|
27
29
|
productDir,
|
|
@@ -54,8 +56,13 @@ export function createAssistantState({
|
|
|
54
56
|
providerArgs,
|
|
55
57
|
}
|
|
56
58
|
);
|
|
57
|
-
let resolvedProviderName =
|
|
59
|
+
let resolvedProviderName = resolveInitialProvider(settings.provider, env);
|
|
58
60
|
let activeStatus = null;
|
|
61
|
+
let contextUsage = buildContextUsage({
|
|
62
|
+
provider: resolvedProviderName || settings.provider,
|
|
63
|
+
model: settings.model,
|
|
64
|
+
prompt: "",
|
|
65
|
+
});
|
|
59
66
|
|
|
60
67
|
inspectState.subscribe(() => {
|
|
61
68
|
commandLog.refresh();
|
|
@@ -270,6 +277,17 @@ export function createAssistantState({
|
|
|
270
277
|
resolvedProviderName = provider;
|
|
271
278
|
notify();
|
|
272
279
|
},
|
|
280
|
+
onPrompt(meta) {
|
|
281
|
+
contextUsage = buildContextUsage({
|
|
282
|
+
provider: meta.provider || settings.provider,
|
|
283
|
+
model: meta.model || settings.model,
|
|
284
|
+
prompt: meta.prompt,
|
|
285
|
+
});
|
|
286
|
+
notify();
|
|
287
|
+
},
|
|
288
|
+
onToolEvent(event) {
|
|
289
|
+
handleAssistantToolEvent(event, appendMessage);
|
|
290
|
+
},
|
|
273
291
|
});
|
|
274
292
|
for (const message of emitted) appendMessage(message);
|
|
275
293
|
} catch (error) {
|
|
@@ -291,6 +309,8 @@ export function createAssistantState({
|
|
|
291
309
|
getSnapshot() {
|
|
292
310
|
return {
|
|
293
311
|
context: buildContextSelection(inspectState.getSnapshot()),
|
|
312
|
+
inspect: inspectState.getSnapshot(),
|
|
313
|
+
productDir,
|
|
294
314
|
messages: [...messages],
|
|
295
315
|
composer: composerState.text,
|
|
296
316
|
composerCursor: composerState.cursor,
|
|
@@ -302,6 +322,7 @@ export function createAssistantState({
|
|
|
302
322
|
effort: settings.effort,
|
|
303
323
|
providerArgs: [...settings.providerArgs],
|
|
304
324
|
activeStatus,
|
|
325
|
+
contextUsage,
|
|
305
326
|
contextPaths: {
|
|
306
327
|
contextPath: commandLog.contextPath,
|
|
307
328
|
summaryPath: commandLog.summaryPath,
|
|
@@ -317,6 +338,13 @@ export function createAssistantState({
|
|
|
317
338
|
return state;
|
|
318
339
|
}
|
|
319
340
|
|
|
341
|
+
function resolveInitialProvider(provider, env) {
|
|
342
|
+
if (provider && provider !== "auto") return provider;
|
|
343
|
+
if (isProviderInstalled("codex", env)) return "codex";
|
|
344
|
+
if (isProviderInstalled("claude", env)) return "claude";
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
320
348
|
async function executeSlashCommand({
|
|
321
349
|
slash,
|
|
322
350
|
state,
|
|
@@ -385,7 +413,18 @@ async function executeSlashCommand({
|
|
|
385
413
|
env,
|
|
386
414
|
commandLog: state.commandLog,
|
|
387
415
|
onEvent(event) {
|
|
388
|
-
if (event.type === "tool-
|
|
416
|
+
if (event.type === "tool-start") {
|
|
417
|
+
appendMessage({
|
|
418
|
+
role: "tool",
|
|
419
|
+
status: "running",
|
|
420
|
+
title: event.title || event.tool || "Tool",
|
|
421
|
+
text: event.message,
|
|
422
|
+
data: {
|
|
423
|
+
command: event.command || null,
|
|
424
|
+
testkitRelated: Boolean(event.testkitRelated),
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
} else if (event.type === "tool-status") {
|
|
389
428
|
state.setNotice(event.message);
|
|
390
429
|
}
|
|
391
430
|
},
|
|
@@ -400,6 +439,20 @@ async function executeSlashCommand({
|
|
|
400
439
|
});
|
|
401
440
|
}
|
|
402
441
|
|
|
442
|
+
function handleAssistantToolEvent(event, appendMessage) {
|
|
443
|
+
if (!event || event.type !== "tool-start") return;
|
|
444
|
+
appendMessage({
|
|
445
|
+
role: "tool",
|
|
446
|
+
status: "running",
|
|
447
|
+
title: event.title || event.tool || "Tool",
|
|
448
|
+
text: event.message || "Running tool",
|
|
449
|
+
data: {
|
|
450
|
+
command: event.command || null,
|
|
451
|
+
testkitRelated: Boolean(event.testkitRelated),
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
403
456
|
function formatSettings(snapshot) {
|
|
404
457
|
const rows = [
|
|
405
458
|
["Provider", snapshot.provider || "auto"],
|
|
@@ -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.
|
|
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 =
|
|
52
|
+
const command = extractShellCommand(args).trim();
|
|
52
53
|
if (!command) throw new Error("shell_exec requires a command string");
|
|
53
54
|
|
|
54
|
-
const shellCommand =
|
|
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,14 +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
|
-
type: "tool-
|
|
67
|
+
type: "tool-start",
|
|
65
68
|
tool: "shell_exec",
|
|
66
|
-
|
|
69
|
+
command: shellCommand.executableCommand,
|
|
70
|
+
rawCommand: command,
|
|
71
|
+
title: shellCommand.title,
|
|
72
|
+
testkitRelated: shellCommand.testkitRelated,
|
|
73
|
+
message: shellCommand.normalized
|
|
74
|
+
? `Running ${shellCommand.displayCommand} (${shellCommand.normalizationReason})`
|
|
75
|
+
: `Running ${shellCommand.displayCommand}`,
|
|
67
76
|
});
|
|
68
77
|
|
|
69
|
-
const result = await execaCommand(
|
|
78
|
+
const result = await execaCommand(shellCommand.executableCommand, {
|
|
70
79
|
cwd: context.productDir,
|
|
71
80
|
reject: false,
|
|
72
81
|
shell: true,
|
|
@@ -84,8 +93,21 @@ async function shellExecTool(args, context) {
|
|
|
84
93
|
commandId,
|
|
85
94
|
cwd: context.productDir,
|
|
86
95
|
raw: command,
|
|
96
|
+
executable: shellCommand.executableCommand,
|
|
97
|
+
normalized: shellCommand.normalized,
|
|
98
|
+
code: result.exitCode ?? 0,
|
|
99
|
+
signal: result.signal ?? null,
|
|
100
|
+
});
|
|
101
|
+
context.onEvent?.({
|
|
102
|
+
type: "tool-exit",
|
|
103
|
+
tool: "shell_exec",
|
|
104
|
+
command: shellCommand.executableCommand,
|
|
105
|
+
rawCommand: command,
|
|
106
|
+
title: shellCommand.title,
|
|
107
|
+
testkitRelated: shellCommand.testkitRelated,
|
|
87
108
|
code: result.exitCode ?? 0,
|
|
88
109
|
signal: result.signal ?? null,
|
|
110
|
+
message: `${shellCommand.displayCommand} exited ${result.exitCode ?? 0}`,
|
|
89
111
|
});
|
|
90
112
|
|
|
91
113
|
if (shellCommand.testkitRelated) {
|
|
@@ -93,13 +115,15 @@ async function shellExecTool(args, context) {
|
|
|
93
115
|
}
|
|
94
116
|
context.commandLog?.refresh?.();
|
|
95
117
|
|
|
96
|
-
const lines = formatCommandResult(
|
|
118
|
+
const lines = formatCommandResult(result, shellCommand);
|
|
97
119
|
return {
|
|
98
120
|
ok: (result.exitCode ?? 0) === 0,
|
|
99
121
|
title: shellCommand.title,
|
|
100
122
|
text: lines.join("\n"),
|
|
101
123
|
data: {
|
|
102
124
|
command,
|
|
125
|
+
executableCommand: shellCommand.executableCommand,
|
|
126
|
+
normalizedCommand: shellCommand.normalized,
|
|
103
127
|
stdout: result.stdout || "",
|
|
104
128
|
stderr: result.stderr || "",
|
|
105
129
|
exitCode: result.exitCode ?? 0,
|
|
@@ -203,42 +227,11 @@ async function searchRepoTool(args, context) {
|
|
|
203
227
|
};
|
|
204
228
|
}
|
|
205
229
|
|
|
206
|
-
function
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
command: "testkit",
|
|
211
|
-
display: normalized,
|
|
212
|
-
title: "testkit command",
|
|
213
|
-
testkitRelated: true,
|
|
214
|
-
};
|
|
230
|
+
function formatCommandResult(result, shellCommand) {
|
|
231
|
+
const lines = [`$ ${shellCommand.displayCommand}`];
|
|
232
|
+
if (shellCommand.normalized) {
|
|
233
|
+
lines.push(`normalized from: ${shellCommand.rawCommand}`);
|
|
215
234
|
}
|
|
216
|
-
if (/^(npx)\s+testkit\b/.test(normalized)) {
|
|
217
|
-
return {
|
|
218
|
-
command: "npx testkit",
|
|
219
|
-
display: normalized,
|
|
220
|
-
title: "npx testkit",
|
|
221
|
-
testkitRelated: true,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
if (/^(npm)\s+run\s+testkit\b/.test(normalized) || /^(npm)\s+run\s+testkit:/.test(normalized)) {
|
|
225
|
-
return {
|
|
226
|
-
command: "npm run testkit",
|
|
227
|
-
display: normalized,
|
|
228
|
-
title: "npm testkit script",
|
|
229
|
-
testkitRelated: true,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
return {
|
|
233
|
-
command: normalized.split(/\s+/)[0] || "command",
|
|
234
|
-
display: normalized,
|
|
235
|
-
title: "Shell command",
|
|
236
|
-
testkitRelated: false,
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function formatCommandResult(command, result, shellCommand) {
|
|
241
|
-
const lines = [`$ ${command}`];
|
|
242
235
|
const stdout = (result.stdout || "").trim();
|
|
243
236
|
const stderr = (result.stderr || "").trim();
|
|
244
237
|
const merged = [];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { formatContextRemaining } from "./context-window.mjs";
|
|
3
|
+
|
|
4
|
+
const MAX_TRANSCRIPT_BLOCKS = 18;
|
|
5
|
+
|
|
6
|
+
export function buildAssistantViewModel(snapshot, { cwd = process.cwd(), terminalWidth = 100 } = {}) {
|
|
7
|
+
const providerLabel = buildProviderLabel(snapshot);
|
|
8
|
+
const repoName = path.basename(cwd || process.cwd()) || "repository";
|
|
9
|
+
return {
|
|
10
|
+
title: `testkit · ${repoName}`,
|
|
11
|
+
welcome: buildWelcomeModel(snapshot, { cwd, providerLabel }),
|
|
12
|
+
blocks: buildTranscriptBlocks(snapshot.messages || []),
|
|
13
|
+
composer: {
|
|
14
|
+
text: snapshot.composer || "",
|
|
15
|
+
cursor: snapshot.composerCursor ?? 0,
|
|
16
|
+
placeholder: "Ask testkit to run, inspect, or explain something",
|
|
17
|
+
},
|
|
18
|
+
statusLine: buildStatusLine(snapshot, { cwd, providerLabel }),
|
|
19
|
+
busy: Boolean(snapshot.busy),
|
|
20
|
+
notice: snapshot.notice || null,
|
|
21
|
+
terminalWidth,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildWelcomeModel(snapshot, { cwd = process.cwd(), providerLabel = null } = {}) {
|
|
26
|
+
const summaryRows = snapshot?.inspect?.summaryData?.rows || snapshot?.summaryData?.rows || [];
|
|
27
|
+
const rowValue = (label) => summaryRows.find(([key]) => key === label)?.[1] || null;
|
|
28
|
+
const contextSelection = snapshot?.context?.selection || {};
|
|
29
|
+
const latestResult = rowValue("Result");
|
|
30
|
+
const counts = [
|
|
31
|
+
rowValue("Passed") ? `${rowValue("Passed")} passed` : null,
|
|
32
|
+
rowValue("Failed") ? `${rowValue("Failed")} failed` : null,
|
|
33
|
+
rowValue("Skipped") ? `${rowValue("Skipped")} skipped` : null,
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
const issues = [
|
|
36
|
+
rowValue("New regressions") ? `${rowValue("New regressions")} new regression${rowValue("New regressions") === "1" ? "" : "s"}` : null,
|
|
37
|
+
rowValue("Known regressions") ? `${rowValue("Known regressions")} known` : null,
|
|
38
|
+
rowValue("Catalog stale") ? `${rowValue("Catalog stale")} stale` : null,
|
|
39
|
+
].filter(Boolean);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
subtitle: "Local testing assistant",
|
|
43
|
+
rows: [
|
|
44
|
+
["Provider", providerLabel || buildProviderLabel(snapshot)],
|
|
45
|
+
["Directory", shortenHome(cwd)],
|
|
46
|
+
["Latest", latestResult ? [latestResult, ...counts].join(" · ") : "No run artifact yet"],
|
|
47
|
+
["Focus", contextSelection.filePath || contextSelection.serviceName || "No focus"],
|
|
48
|
+
["Issues", issues.length ? issues.join(" · ") : "None detected"],
|
|
49
|
+
],
|
|
50
|
+
suggestions: buildSuggestions({ latestResult, contextSelection, hasArtifact: Boolean(latestResult) }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildTranscriptBlocks(messages) {
|
|
55
|
+
return (messages || []).slice(-MAX_TRANSCRIPT_BLOCKS).map((message) => {
|
|
56
|
+
const role = message.role || "system";
|
|
57
|
+
if (role === "tool") {
|
|
58
|
+
return {
|
|
59
|
+
id: message.id,
|
|
60
|
+
kind: classifyToolBlock(message),
|
|
61
|
+
marker: "●",
|
|
62
|
+
title: message.title || message.toolName || "Tool",
|
|
63
|
+
text: message.text || "",
|
|
64
|
+
status: message.status || null,
|
|
65
|
+
command: message.data?.command || null,
|
|
66
|
+
exitCode: message.data?.exitCode ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (role === "user") {
|
|
70
|
+
return {
|
|
71
|
+
id: message.id,
|
|
72
|
+
kind: "user",
|
|
73
|
+
marker: "❯",
|
|
74
|
+
text: message.text || "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (role === "assistant") {
|
|
78
|
+
return {
|
|
79
|
+
id: message.id,
|
|
80
|
+
kind: "assistant",
|
|
81
|
+
marker: "●",
|
|
82
|
+
text: message.text || "",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
id: message.id,
|
|
87
|
+
kind: "system",
|
|
88
|
+
marker: "!",
|
|
89
|
+
text: message.text || "",
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildStatusLine(snapshot, { cwd = process.cwd(), providerLabel = null } = {}) {
|
|
95
|
+
const context = formatContextRemaining(snapshot.contextUsage);
|
|
96
|
+
const provider = providerLabel || buildProviderLabel(snapshot);
|
|
97
|
+
const status = snapshot.busy ? snapshot.activeStatus || "working" : "/settings";
|
|
98
|
+
return `${context} ${shortenHome(cwd)} · ${provider} · ${status}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildProviderLabel(snapshot) {
|
|
102
|
+
const provider = snapshot?.provider || "auto";
|
|
103
|
+
const resolved = snapshot?.resolvedProvider && snapshot.resolvedProvider !== provider ? `→${snapshot.resolvedProvider}` : "";
|
|
104
|
+
const model = snapshot?.model ? ` ${snapshot.model}` : "";
|
|
105
|
+
const effort = snapshot?.effort ? ` ${snapshot.effort}` : "";
|
|
106
|
+
return `${provider}${resolved}${model}${effort}`.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildSuggestions({ latestResult, contextSelection, hasArtifact }) {
|
|
110
|
+
if (!hasArtifact) {
|
|
111
|
+
return ["Run all tests", "Discover tests", "Run doctor checks"];
|
|
112
|
+
}
|
|
113
|
+
if (latestResult === "FAILED") {
|
|
114
|
+
const suggestions = ["Explain the latest failure", "Show new regressions", "Inspect logs"];
|
|
115
|
+
if (contextSelection?.filePath) suggestions.push(`Inspect ${path.basename(contextSelection.filePath)}`);
|
|
116
|
+
return suggestions;
|
|
117
|
+
}
|
|
118
|
+
return ["Run e2e tests", "Show latest summary", "List test files"];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function classifyToolBlock(message) {
|
|
122
|
+
if (message.status === "running") return "tool-running";
|
|
123
|
+
if (message.data?.testkitRelated) return "testkit-run";
|
|
124
|
+
return "tool-result";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function shortenHome(value) {
|
|
128
|
+
const text = String(value || "");
|
|
129
|
+
const home = process.env.HOME;
|
|
130
|
+
if (home && text.startsWith(home)) return `~${text.slice(home.length)}`;
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
@@ -6,25 +6,7 @@ import {
|
|
|
6
6
|
expectStatus,
|
|
7
7
|
expectStatusOneOf,
|
|
8
8
|
} from "./http-assertions.js";
|
|
9
|
-
|
|
10
|
-
const DEFAULT_PAGINATION_CASES = [
|
|
11
|
-
{ qs: "limit=0", label: "limit=0", expect400: false },
|
|
12
|
-
{ qs: "limit=-1", label: "limit=-1", expect400: true },
|
|
13
|
-
{ qs: "limit=999999", label: "limit=999999", expect400: false },
|
|
14
|
-
{ qs: "limit=abc", label: "limit=abc", expect400: true },
|
|
15
|
-
{ qs: "offset=-1", label: "offset=-1", expect400: true },
|
|
16
|
-
{ qs: "offset=1.5", label: "offset=1.5", expect400: true },
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const AUDIT_LOGS_PAGINATION_CASES = [
|
|
20
|
-
{ qs: "limit=1e3", label: "limit=1e3 (scientific notation)" },
|
|
21
|
-
{ qs: "limit=Infinity", label: "limit=Infinity" },
|
|
22
|
-
{ qs: "limit=NaN", label: "limit=NaN" },
|
|
23
|
-
{ qs: "offset=NaN", label: "offset=NaN" },
|
|
24
|
-
{ qs: "limit=", label: "limit= (empty)" },
|
|
25
|
-
{ qs: "offset=", label: "offset= (empty)" },
|
|
26
|
-
{ qs: "limit=0x10", label: "limit=0x10 (hex)" },
|
|
27
|
-
];
|
|
9
|
+
import { buildPaginationCases, normalizeRequestCase } from "../shared/http-check-plan.mjs";
|
|
28
10
|
|
|
29
11
|
export function runAuthGateChecks(rawReq, scope, descriptors = {}) {
|
|
30
12
|
const {
|
|
@@ -45,43 +27,33 @@ export function runAuthGateChecks(rawReq, scope, descriptors = {}) {
|
|
|
45
27
|
|
|
46
28
|
export function runPaginationChecks(req, endpoint, options = {}) {
|
|
47
29
|
group(`${endpoint} — pagination abuse`, () => {
|
|
48
|
-
for (const {
|
|
49
|
-
const url = `${endpoint}?${qs}`;
|
|
30
|
+
for (const { label, expect400, url, auditOnly } of buildPaginationCases(endpoint, options)) {
|
|
50
31
|
const response = req.get(url);
|
|
51
32
|
|
|
52
|
-
expectNotStatus(response, 500, `${label} → not 500`);
|
|
33
|
+
expectNotStatus(response, 500, auditOnly ? `audit-logs ${label} → not 500` : `${label} → not 500`);
|
|
53
34
|
if (response.status === 500) {
|
|
54
|
-
expectResponse(
|
|
35
|
+
expectResponse(
|
|
36
|
+
response,
|
|
37
|
+
() => true,
|
|
38
|
+
auditOnly ? `BUG: audit-logs crashes on ${label}` : `BUG: ${endpoint} crashes on ${label}`
|
|
39
|
+
);
|
|
55
40
|
}
|
|
56
41
|
|
|
57
|
-
if (
|
|
42
|
+
if (auditOnly) {
|
|
43
|
+
expectStatusOneOf(response, [400, 200], `audit-logs ${label} → 400 (not silently accepted)`);
|
|
44
|
+
} else if (expect400) {
|
|
58
45
|
expectStatus(response, 400, `${label} → 400`);
|
|
59
46
|
if (response.status === 200) {
|
|
60
47
|
expectResponse(response, () => true, `BUG: ${endpoint} accepts ${label}`);
|
|
61
48
|
}
|
|
62
49
|
}
|
|
63
50
|
|
|
64
|
-
if (label === "limit=abc" && response.body) {
|
|
65
|
-
expectResponse(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
for (const { qs, label } of AUDIT_LOGS_PAGINATION_CASES) {
|
|
74
|
-
const url = `${endpoint}?${qs}`;
|
|
75
|
-
const response = req.get(url);
|
|
76
|
-
|
|
77
|
-
expectNotStatus(response, 500, `audit-logs ${label} → not 500`);
|
|
78
|
-
if (response.status === 500) {
|
|
79
|
-
expectResponse(response, () => true, `BUG: audit-logs crashes on ${label}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
expectStatusOneOf(response, [400, 200], `audit-logs ${label} → 400 (not silently accepted)`);
|
|
83
|
-
if (response.status === 200 && response.body) {
|
|
84
|
-
expectResponse(response, (value) => !value.body.includes("NaN"), `audit-logs ${label} → no NaN in response`);
|
|
51
|
+
if ((auditOnly || label === "limit=abc") && response.status === 200 && response.body) {
|
|
52
|
+
expectResponse(
|
|
53
|
+
response,
|
|
54
|
+
(value) => !value.body.includes("NaN"),
|
|
55
|
+
auditOnly ? `audit-logs ${label} → no NaN in response` : `${label} → no NaN in response`
|
|
56
|
+
);
|
|
85
57
|
}
|
|
86
58
|
}
|
|
87
59
|
});
|
|
@@ -104,17 +76,3 @@ function runMethodAuthGateChecks(rawReq, scope, method, cases, validateErrorShap
|
|
|
104
76
|
}
|
|
105
77
|
});
|
|
106
78
|
}
|
|
107
|
-
|
|
108
|
-
function normalizeRequestCase(entry) {
|
|
109
|
-
if (Array.isArray(entry)) {
|
|
110
|
-
return {
|
|
111
|
-
path: entry[0],
|
|
112
|
-
body: entry[1],
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
path: entry,
|
|
118
|
-
body: undefined,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const DEFAULT_PAGINATION_CASES = [
|
|
2
|
+
{ qs: "limit=0", label: "limit=0", expect400: false },
|
|
3
|
+
{ qs: "limit=-1", label: "limit=-1", expect400: true },
|
|
4
|
+
{ qs: "limit=999999", label: "limit=999999", expect400: false },
|
|
5
|
+
{ qs: "limit=abc", label: "limit=abc", expect400: true },
|
|
6
|
+
{ qs: "offset=-1", label: "offset=-1", expect400: true },
|
|
7
|
+
{ qs: "offset=1.5", label: "offset=1.5", expect400: true },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export const AUDIT_LOGS_PAGINATION_CASES = [
|
|
11
|
+
{ qs: "limit=1e3", label: "limit=1e3 (scientific notation)" },
|
|
12
|
+
{ qs: "limit=Infinity", label: "limit=Infinity" },
|
|
13
|
+
{ qs: "limit=NaN", label: "limit=NaN" },
|
|
14
|
+
{ qs: "offset=NaN", label: "offset=NaN" },
|
|
15
|
+
{ qs: "limit=", label: "limit= (empty)" },
|
|
16
|
+
{ qs: "offset=", label: "offset= (empty)" },
|
|
17
|
+
{ qs: "limit=0x10", label: "limit=0x10 (hex)" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function normalizeRequestCase(entry) {
|
|
21
|
+
if (Array.isArray(entry)) {
|
|
22
|
+
return {
|
|
23
|
+
path: entry[0],
|
|
24
|
+
body: entry[1],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
path: entry,
|
|
30
|
+
body: undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildPaginationCases(endpoint, options = {}) {
|
|
35
|
+
const cases = DEFAULT_PAGINATION_CASES.map((entry) => ({
|
|
36
|
+
...entry,
|
|
37
|
+
url: `${endpoint}?${entry.qs}`,
|
|
38
|
+
auditOnly: false,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
if (!options.auditLogsExtra) {
|
|
42
|
+
return cases;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
...cases,
|
|
47
|
+
...AUDIT_LOGS_PAGINATION_CASES.map((entry) => ({
|
|
48
|
+
...entry,
|
|
49
|
+
url: `${endpoint}?${entry.qs}`,
|
|
50
|
+
auditOnly: true,
|
|
51
|
+
})),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.96"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|