@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.
@@ -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
- "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:",
@@ -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 = null;
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-status") {
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. 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,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-status",
67
+ type: "tool-start",
65
68
  tool: "shell_exec",
66
- message: `Running ${shellCommand.display}`,
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(command, {
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(command, result, shellCommand);
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 classifyShellCommand(command) {
207
- const normalized = command.trim();
208
- if (/^(testkit)\b/.test(normalized)) {
209
- return {
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 { qs, label, expect400 } of DEFAULT_PAGINATION_CASES) {
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(response, () => true, `BUG: ${endpoint} crashes on ${label}`);
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 (expect400) {
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(response, (value) => !value.body.includes("NaN"), `${label} → no NaN in response`);
66
- }
67
- }
68
-
69
- if (!options.auditLogsExtra) {
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/next-analysis",
3
- "version": "0.1.93",
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.93",
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.93"
25
+ "@elench/testkit-protocol": "0.1.96"
26
26
  },
27
27
  "private": false
28
28
  }