explorbot 0.4.6 → 0.4.8

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.
Files changed (74) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +74 -66
  3. package/boat/api-tester/src/apibot.ts +1 -0
  4. package/boat/api-tester/src/cli.ts +2 -0
  5. package/boat/api-tester/src/config.ts +18 -1
  6. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  7. package/dist/boat/api-tester/src/ai/curler.js +59 -56
  8. package/dist/boat/api-tester/src/apibot.js +1 -0
  9. package/dist/boat/api-tester/src/cli.js +2 -0
  10. package/dist/boat/api-tester/src/config.js +3 -1
  11. package/dist/package.json +2 -2
  12. package/dist/rules/chief/general.md +2 -0
  13. package/dist/rules/researcher/pagination.md +7 -0
  14. package/dist/src/action-result.d.ts +6 -0
  15. package/dist/src/action-result.js +12 -0
  16. package/dist/src/action.js +3 -2
  17. package/dist/src/ai/navigator.js +1 -4
  18. package/dist/src/ai/pilot.js +8 -12
  19. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  20. package/dist/src/ai/planner/session-dedup.js +18 -1
  21. package/dist/src/ai/planner.js +12 -4
  22. package/dist/src/ai/provider.js +18 -4
  23. package/dist/src/ai/researcher/locators.js +1 -1
  24. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  25. package/dist/src/ai/researcher/pagination.js +62 -0
  26. package/dist/src/ai/researcher/parser.d.ts +3 -0
  27. package/dist/src/ai/researcher/parser.js +22 -6
  28. package/dist/src/ai/researcher/sections.js +1 -1
  29. package/dist/src/ai/researcher.js +7 -2
  30. package/dist/src/ai/rules.js +17 -0
  31. package/dist/src/ai/scout.js +8 -2
  32. package/dist/src/ai/tester.js +1 -1
  33. package/dist/src/ai/tools.js +12 -4
  34. package/dist/src/commands/options/ws-option.d.ts +7 -0
  35. package/dist/src/commands/options/ws-option.js +14 -0
  36. package/dist/src/config.d.ts +1 -0
  37. package/dist/src/config.js +14 -11
  38. package/dist/src/remote.d.ts +2 -0
  39. package/dist/src/remote.js +23 -16
  40. package/dist/src/utils/aria.d.ts +2 -0
  41. package/dist/src/utils/aria.js +6 -1
  42. package/dist/src/utils/code-extractor.js +6 -2
  43. package/dist/src/utils/markdown-query.d.ts +2 -0
  44. package/dist/src/utils/markdown-query.js +39 -0
  45. package/dist/src/utils/pagination.d.ts +16 -0
  46. package/dist/src/utils/pagination.js +20 -0
  47. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  48. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  49. package/package.json +2 -2
  50. package/rules/chief/general.md +2 -0
  51. package/rules/researcher/pagination.md +7 -0
  52. package/src/action-result.ts +16 -0
  53. package/src/action.ts +3 -2
  54. package/src/ai/navigator.ts +1 -4
  55. package/src/ai/pilot.ts +8 -12
  56. package/src/ai/planner/session-dedup.ts +16 -2
  57. package/src/ai/planner.ts +12 -4
  58. package/src/ai/provider.ts +18 -3
  59. package/src/ai/researcher/locators.ts +1 -1
  60. package/src/ai/researcher/pagination.ts +68 -0
  61. package/src/ai/researcher/parser.ts +23 -5
  62. package/src/ai/researcher/sections.ts +1 -1
  63. package/src/ai/researcher.ts +9 -3
  64. package/src/ai/rules.ts +17 -0
  65. package/src/ai/scout.ts +9 -2
  66. package/src/ai/tester.ts +1 -1
  67. package/src/ai/tools.ts +9 -4
  68. package/src/commands/options/ws-option.ts +14 -0
  69. package/src/config.ts +15 -11
  70. package/src/remote.ts +22 -15
  71. package/src/utils/aria.ts +8 -1
  72. package/src/utils/code-extractor.ts +6 -2
  73. package/src/utils/markdown-query.ts +39 -0
  74. package/src/utils/pagination.ts +36 -0
@@ -261,7 +261,9 @@ export class Chief extends ChiefBase {
261
261
  - Use real enum values discovered in the data
262
262
  - Each test MUST use DIFFERENT data — never reuse the same field values across tests
263
263
  - For "create" tests: base payload on a real record but change field values to create new unique data
264
- - For "update" tests: pick a real existing ID and modify specific fields
264
+ - Treat records and IDs from sample_data as read-only. Never update, patch, delete, archive, or otherwise mutate them
265
+ - For update/delete tests: the same scenario must first create its own target, then mutate only that target
266
+ - For negative or unsupported-method tests that could mutate data if accepted: create a scenario-owned target first; if that setup is impossible, do not send the destructive request
265
267
  - For tests needing parent references: use real _id field values from sample_data
266
268
  `);
267
269
  }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type { RequestStore } from '../../../../src/api/request-store.ts';
5
5
  import type { KnowledgeTracker } from '../../../../src/knowledge-tracker.ts';
6
+ import { Observability } from '../../../../src/observability.ts';
6
7
  import type { Reporter } from '../../../../src/reporter.ts';
7
8
  import { type Test, TestResult } from '../../../../src/test-plan.ts';
8
9
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
@@ -46,75 +47,77 @@ export class Curler {
46
47
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
47
48
  conversation.addUserText(initialPrompt);
48
49
 
49
- await loop(
50
- async ({ stop, iteration }) => {
51
- debugLog(`Iteration ${iteration}`);
52
-
53
- if (iteration > 1) {
54
- const requestLog = this.requestState.toLog();
55
- const nextStep = dedent`
56
- <request_log>
57
- ${requestLog || 'No requests made yet'}
58
- </request_log>
59
-
60
- <task>
61
- Continue testing. Review the request log above and proceed with the next step.
62
- </task>
63
-
64
- <notes>
65
- ${test.notesToString() || 'No notes yet'}
66
- </notes>
67
- `;
68
- conversation.addUserText(nextStep);
69
- }
70
-
71
- const result = await this.provider.invokeConversation(conversation, tools, {
72
- maxToolRoundtrips: 5,
73
- toolChoice: 'required',
74
- agentName: 'curler',
75
- });
76
-
77
- if (!result) throw new Error('Failed to get response from provider');
78
-
79
- const toolNames = result.toolExecutions?.map((e: any) => e.toolName) || [];
80
- debugLog('Tool calls:', toolNames.join(', '));
81
-
82
- if (test.hasFinished) {
83
- stop();
84
- return;
85
- }
86
-
87
- if (iteration >= MAX_ITERATIONS) {
88
- tag('warning').log('Max iterations reached, running final review...');
89
- stop();
90
- }
91
- },
50
+ await Observability.run(
51
+ `curler: ${test.scenario}`,
92
52
  {
93
- maxAttempts: MAX_ITERATIONS,
94
- observability: {
95
- name: `curler: ${test.scenario}`,
96
- agent: 'curler',
97
- sessionId: test.sessionName,
98
- metadata: {
99
- input: {
100
- scenario: test.scenario,
101
- startUrl: test.startUrl,
102
- expected: test.expected,
103
- },
104
- },
105
- },
106
- catch: async ({ error, stop }) => {
107
- tag('error').log(`Test execution error: ${error}`);
108
- stop();
53
+ sessionId: test.sessionName,
54
+ tags: ['curler'],
55
+ input: {
56
+ scenario: test.scenario,
57
+ startUrl: test.startUrl,
58
+ expected: test.expected,
109
59
  },
60
+ },
61
+ async () => {
62
+ await loop(
63
+ async ({ stop, iteration }) => {
64
+ debugLog(`Iteration ${iteration}`);
65
+
66
+ if (iteration > 1) {
67
+ const requestLog = this.requestState.toLog();
68
+ const nextStep = dedent`
69
+ <request_log>
70
+ ${requestLog || 'No requests made yet'}
71
+ </request_log>
72
+
73
+ <task>
74
+ Continue testing. Review the request log above and proceed with the next step.
75
+ </task>
76
+
77
+ <notes>
78
+ ${test.notesToString() || 'No notes yet'}
79
+ </notes>
80
+ `;
81
+ conversation.addUserText(nextStep);
82
+ }
83
+
84
+ const result = await this.provider.invokeConversation(conversation, tools, {
85
+ maxToolRoundtrips: 5,
86
+ toolChoice: 'required',
87
+ agentName: 'curler',
88
+ });
89
+
90
+ if (!result) throw new Error('Failed to get response from provider');
91
+
92
+ const toolNames = result.toolExecutions?.map((e: any) => e.toolName) || [];
93
+ debugLog('Tool calls:', toolNames.join(', '));
94
+
95
+ if (test.hasFinished) {
96
+ stop();
97
+ return;
98
+ }
99
+
100
+ if (iteration >= MAX_ITERATIONS) {
101
+ tag('warning').log('Max iterations reached, running final review...');
102
+ stop();
103
+ }
104
+ },
105
+ {
106
+ maxAttempts: MAX_ITERATIONS,
107
+ catch: async ({ error, stop }) => {
108
+ tag('error').log(`Test execution error: ${error}`);
109
+ stop();
110
+ },
111
+ }
112
+ );
113
+
114
+ try {
115
+ await this.finalReview(test);
116
+ } catch (error) {
117
+ tag('error').log(`Final review failed: ${error}`);
118
+ }
110
119
  }
111
120
  );
112
-
113
- try {
114
- await this.finalReview(test);
115
- } catch (error) {
116
- tag('error').log(`Final review failed: ${error}`);
117
- }
118
121
  this.finishTest(test);
119
122
  const meta: Record<string, string | undefined> = {
120
123
  endpoint: test.startUrl,
@@ -190,7 +193,8 @@ export class Curler {
190
193
  },
191
194
  ],
192
195
  schema,
193
- model
196
+ model,
197
+ { agentName: 'curler', telemetryFunctionId: 'curler.finalReview' }
194
198
  );
195
199
 
196
200
  const result = response?.object;
@@ -280,6 +284,10 @@ export class Curler {
280
284
  - Record important findings as you go
281
285
  - Be precise about what you expect vs what you observe
282
286
  - If a test requires data from another endpoint, use schemaFor to look it up before guessing
287
+ - Treat existing records, sample data, and IDs supplied by the plan as read-only
288
+ - Before PUT, PATCH, DELETE, archive, or another destructive request, create the target inside the current scenario and mutate only that target
289
+ - This also applies when testing an unsupported method: the server may unexpectedly accept it, so never probe destructively against pre-existing data
290
+ - If a scenario-owned target cannot be created, use stop rather than risking existing data
283
291
  </rules>
284
292
  `;
285
293
  }
@@ -85,6 +85,7 @@ export class ApiBot {
85
85
  async stop(): Promise<void> {
86
86
  await this.reporter?.finishRun();
87
87
  await this.apiClient?.teardown();
88
+ await this.provider?.stop();
88
89
  }
89
90
 
90
91
  createAgent<T>(factory: (deps: { ai: AIProvider; config: ApibotConfig; apiClient: ApiClient; requestState: RequestStore; knowledge: KnowledgeTracker }) => T): T {
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { flushTelemetry } from '../../../src/ai/provider.ts';
2
3
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
3
4
  import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
4
5
  import { listSites } from '../../../src/global-config.ts';
@@ -111,6 +112,7 @@ async function run(name: string, options: any, endpoint: string | undefined, bod
111
112
  process.exit(code);
112
113
  } catch (error) {
113
114
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
115
+ await flushTelemetry();
114
116
  process.exit(1);
115
117
  }
116
118
  }
@@ -2,7 +2,22 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { parseEnv } from 'node:util';
5
- import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from '../../../src/config.ts';
5
+ import {
6
+ type AIConfig,
7
+ type ApiHookFn,
8
+ type ApiConfig as BaseApiConfig,
9
+ ConfigMissingError,
10
+ EXPLORBOT_CONFIG_PATHS,
11
+ createModel,
12
+ envConfigRequested,
13
+ materializeKnowledge,
14
+ missingConfigMessage,
15
+ resolveConfigModels,
16
+ resolveLangfuse,
17
+ resolveModel,
18
+ resolveOutputRoot,
19
+ setOutputDir,
20
+ } from '../../../src/config.ts';
6
21
  import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
7
22
 
8
23
  export type { AIConfig };
@@ -101,6 +116,7 @@ export class ApibotConfigParser {
101
116
  this.applyEnvHeaders(this.config.api);
102
117
  if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
103
118
  await resolveConfigModels(this.config.ai);
119
+ resolveLangfuse(this.config.ai);
104
120
  this.configPath = resolvedPath;
105
121
  this.site = null;
106
122
 
@@ -236,6 +252,7 @@ export class ApibotConfigParser {
236
252
  api,
237
253
  dirs: { output: '.', knowledge: 'knowledge' },
238
254
  };
255
+ resolveLangfuse(this.config.ai);
239
256
  this.configPath = path.join(outputRoot, 'apibot.config.js');
240
257
  this.validateConfig(this.config);
241
258
  setOutputDir(this.getOutputDir());
@@ -230,7 +230,9 @@ export class Chief extends ChiefBase {
230
230
  - Use real enum values discovered in the data
231
231
  - Each test MUST use DIFFERENT data — never reuse the same field values across tests
232
232
  - For "create" tests: base payload on a real record but change field values to create new unique data
233
- - For "update" tests: pick a real existing ID and modify specific fields
233
+ - Treat records and IDs from sample_data as read-only. Never update, patch, delete, archive, or otherwise mutate them
234
+ - For update/delete tests: the same scenario must first create its own target, then mutate only that target
235
+ - For negative or unsupported-method tests that could mutate data if accepted: create a scenario-owned target first; if that setup is impossible, do not send the destructive request
234
236
  - For tests needing parent references: use real _id field values from sample_data
235
237
  `);
236
238
  }
@@ -1,5 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
+ import { Observability } from "../../../../src/observability.js";
3
4
  import { TestResult } from "../../../../src/test-plan.js";
4
5
  import { createDebug, tag } from "../../../../src/utils/logger.js";
5
6
  import { loop } from "../../../../src/utils/loop.js";
@@ -32,67 +33,65 @@ export class Curler {
32
33
  conversation.addUserText(knowledge);
33
34
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
34
35
  conversation.addUserText(initialPrompt);
35
- await loop(async ({ stop, iteration }) => {
36
- debugLog(`Iteration ${iteration}`);
37
- if (iteration > 1) {
38
- const requestLog = this.requestState.toLog();
39
- const nextStep = dedent `
40
- <request_log>
41
- ${requestLog || 'No requests made yet'}
42
- </request_log>
36
+ await Observability.run(`curler: ${test.scenario}`, {
37
+ sessionId: test.sessionName,
38
+ tags: ['curler'],
39
+ input: {
40
+ scenario: test.scenario,
41
+ startUrl: test.startUrl,
42
+ expected: test.expected,
43
+ },
44
+ }, async () => {
45
+ await loop(async ({ stop, iteration }) => {
46
+ debugLog(`Iteration ${iteration}`);
47
+ if (iteration > 1) {
48
+ const requestLog = this.requestState.toLog();
49
+ const nextStep = dedent `
50
+ <request_log>
51
+ ${requestLog || 'No requests made yet'}
52
+ </request_log>
43
53
 
44
- <task>
45
- Continue testing. Review the request log above and proceed with the next step.
46
- </task>
54
+ <task>
55
+ Continue testing. Review the request log above and proceed with the next step.
56
+ </task>
47
57
 
48
- <notes>
49
- ${test.notesToString() || 'No notes yet'}
50
- </notes>
51
- `;
52
- conversation.addUserText(nextStep);
53
- }
54
- const result = await this.provider.invokeConversation(conversation, tools, {
55
- maxToolRoundtrips: 5,
56
- toolChoice: 'required',
57
- agentName: 'curler',
58
+ <notes>
59
+ ${test.notesToString() || 'No notes yet'}
60
+ </notes>
61
+ `;
62
+ conversation.addUserText(nextStep);
63
+ }
64
+ const result = await this.provider.invokeConversation(conversation, tools, {
65
+ maxToolRoundtrips: 5,
66
+ toolChoice: 'required',
67
+ agentName: 'curler',
68
+ });
69
+ if (!result)
70
+ throw new Error('Failed to get response from provider');
71
+ const toolNames = result.toolExecutions?.map((e) => e.toolName) || [];
72
+ debugLog('Tool calls:', toolNames.join(', '));
73
+ if (test.hasFinished) {
74
+ stop();
75
+ return;
76
+ }
77
+ if (iteration >= MAX_ITERATIONS) {
78
+ tag('warning').log('Max iterations reached, running final review...');
79
+ stop();
80
+ }
81
+ }, {
82
+ maxAttempts: MAX_ITERATIONS,
83
+ catch: async ({ error, stop }) => {
84
+ tag('error').log(`Test execution error: ${error}`);
85
+ stop();
86
+ },
58
87
  });
59
- if (!result)
60
- throw new Error('Failed to get response from provider');
61
- const toolNames = result.toolExecutions?.map((e) => e.toolName) || [];
62
- debugLog('Tool calls:', toolNames.join(', '));
63
- if (test.hasFinished) {
64
- stop();
65
- return;
88
+ try {
89
+ await this.finalReview(test);
66
90
  }
67
- if (iteration >= MAX_ITERATIONS) {
68
- tag('warning').log('Max iterations reached, running final review...');
69
- stop();
91
+ catch (error) {
92
+ tag('error').log(`Final review failed: ${error}`);
70
93
  }
71
- }, {
72
- maxAttempts: MAX_ITERATIONS,
73
- observability: {
74
- name: `curler: ${test.scenario}`,
75
- agent: 'curler',
76
- sessionId: test.sessionName,
77
- metadata: {
78
- input: {
79
- scenario: test.scenario,
80
- startUrl: test.startUrl,
81
- expected: test.expected,
82
- },
83
- },
84
- },
85
- catch: async ({ error, stop }) => {
86
- tag('error').log(`Test execution error: ${error}`);
87
- stop();
88
- },
89
94
  });
90
- try {
91
- await this.finalReview(test);
92
- }
93
- catch (error) {
94
- tag('error').log(`Final review failed: ${error}`);
95
- }
96
95
  this.finishTest(test);
97
96
  const meta = {
98
97
  endpoint: test.startUrl,
@@ -160,7 +159,7 @@ export class Curler {
160
159
  3. Should the test pass or fail overall?
161
160
  `,
162
161
  },
163
- ], schema, model);
162
+ ], schema, model, { agentName: 'curler', telemetryFunctionId: 'curler.finalReview' });
164
163
  const result = response?.object;
165
164
  if (!result)
166
165
  return;
@@ -244,6 +243,10 @@ export class Curler {
244
243
  - Record important findings as you go
245
244
  - Be precise about what you expect vs what you observe
246
245
  - If a test requires data from another endpoint, use schemaFor to look it up before guessing
246
+ - Treat existing records, sample data, and IDs supplied by the plan as read-only
247
+ - Before PUT, PATCH, DELETE, archive, or another destructive request, create the target inside the current scenario and mutate only that target
248
+ - This also applies when testing an unsupported method: the server may unexpectedly accept it, so never probe destructively against pre-existing data
249
+ - If a scenario-owned target cannot be created, use stop rather than risking existing data
247
250
  </rules>
248
251
  `;
249
252
  }
@@ -74,6 +74,7 @@ export class ApiBot {
74
74
  async stop() {
75
75
  await this.reporter?.finishRun();
76
76
  await this.apiClient?.teardown();
77
+ await this.provider?.stop();
77
78
  }
78
79
  createAgent(factory) {
79
80
  return factory({
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { flushTelemetry } from "../../../src/ai/provider.js";
2
3
  import { ConfigCommand } from "../../../src/commands/config-command.js";
3
4
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
4
5
  import { listSites } from "../../../src/global-config.js";
@@ -108,6 +109,7 @@ async function run(name, options, endpoint, body) {
108
109
  }
109
110
  catch (error) {
110
111
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
112
+ await flushTelemetry();
111
113
  process.exit(1);
112
114
  }
113
115
  }
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
- import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from "../../../src/config.js";
13
+ import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
14
14
  import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
15
  function isAbsoluteEndpoint(value) {
16
16
  return !!value && (value.startsWith('http://') || value.startsWith('https://'));
@@ -82,6 +82,7 @@ export class ApibotConfigParser {
82
82
  if (options?.baseEndpoint)
83
83
  this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
84
  await resolveConfigModels(this.config.ai);
85
+ resolveLangfuse(this.config.ai);
85
86
  this.configPath = resolvedPath;
86
87
  this.site = null;
87
88
  if (isGlobalConfigPath(resolvedPath)) {
@@ -208,6 +209,7 @@ export class ApibotConfigParser {
208
209
  api,
209
210
  dirs: { output: '.', knowledge: 'knowledge' },
210
211
  };
212
+ resolveLangfuse(this.config.ai);
211
213
  this.configPath = path.join(outputRoot, 'apibot.config.js');
212
214
  this.validateConfig(this.config);
213
215
  setOutputDir(this.getOutputDir());
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "bash-tool": "^1.3.15",
102
102
  "chalk": "^5.6.2",
103
103
  "cli-highlight": "^2.1.11",
104
- "codeceptjs": "4.2.0-beta.2",
104
+ "codeceptjs": "^4.2.0-beta.3",
105
105
  "commander": "^14.0.1",
106
106
  "debug": "^4.4.3",
107
107
  "dedent": "^1.6.0",
@@ -2,6 +2,8 @@
2
2
  - Steps should specify exact HTTP methods, paths, and key payload details
3
3
  - Expected outcomes should be specific and verifiable (status codes, response fields, error messages)
4
4
  - For CRUD operations, each test should handle its own setup and teardown
5
+ - Treat existing records and IDs discovered from the API, knowledge, or sample data as read-only
6
+ - A scenario that updates, patches, deletes, archives, or otherwise mutates a record must create that target inside the same scenario first; omit the scenario if safe setup is impossible
5
7
  - Expect standard REST conventions: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, 422 Unprocessable Entity
6
8
  - NEVER propose scenarios that test the same thing. "Create a basic suite" and "Successful creation of a simple suite" are DUPLICATES. Each scenario must test a DISTINCT behavior or aspect.
7
9
  - Before finalizing, review all scenarios and remove any that overlap in what they actually verify.
@@ -0,0 +1,7 @@
1
+ <pagination>
2
+ When a section is a list that continues beyond what is shown, add one line under its `> Container:` line:
3
+ `> Pagination: controls` — it has page numbers (1, 2, 3), prev/next arrows, or a "load more" button.
4
+ `> Pagination: infinite` — it has none of those and loads more as it is scrolled.
5
+ Omit the line when the items already shown are the whole collection.
6
+ Sorting, filtering and switching tabs are not pagination — omit the line then.
7
+ </pagination>
@@ -37,6 +37,8 @@ export interface PageDiff {
37
37
  currentUrl: string;
38
38
  ariaChanges?: string | null;
39
39
  ariaChangeCount?: number;
40
+ ariaAdded?: number;
41
+ ariaRemoved?: number;
40
42
  messages?: string[];
41
43
  requests?: NetworkCall[];
42
44
  consoleErrors?: string[];
@@ -137,6 +139,8 @@ export declare class Diff {
137
139
  _messages: string[];
138
140
  _ariaDiffResult: string | null;
139
141
  _ariaChangeCount: number;
142
+ _ariaAdded: number;
143
+ _ariaRemoved: number;
140
144
  _isSameUrl: boolean;
141
145
  constructor(current: ActionResult, previous: ActionResult | null);
142
146
  static create(current: ActionResult, previous: ActionResult | null): Promise<Diff>;
@@ -147,6 +151,8 @@ export declare class Diff {
147
151
  cleanedHtmlParts(): Promise<HtmlDiffPart[]>;
148
152
  get ariaChanged(): string | null;
149
153
  get ariaChangeCount(): number;
154
+ get ariaAdded(): number;
155
+ get ariaRemoved(): number;
150
156
  get htmlDiff(): HtmlDiffResult | null;
151
157
  get messages(): string[];
152
158
  get similarity(): number;
@@ -451,6 +451,8 @@ export class ActionResult {
451
451
  if (diff.ariaChanged) {
452
452
  pageDiff.ariaChanges = diff.ariaChanged;
453
453
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
454
+ pageDiff.ariaAdded = diff.ariaAdded;
455
+ pageDiff.ariaRemoved = diff.ariaRemoved;
454
456
  }
455
457
  if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
456
458
  pageDiff.areaOfInterest = this.overlay.describe();
@@ -542,6 +544,8 @@ export class Diff {
542
544
  _messages = [];
543
545
  _ariaDiffResult = null;
544
546
  _ariaChangeCount = 0;
547
+ _ariaAdded = 0;
548
+ _ariaRemoved = 0;
545
549
  _isSameUrl;
546
550
  constructor(current, previous) {
547
551
  this.current = current;
@@ -590,6 +594,12 @@ export class Diff {
590
594
  get ariaChangeCount() {
591
595
  return this._ariaChangeCount;
592
596
  }
597
+ get ariaAdded() {
598
+ return this._ariaAdded;
599
+ }
600
+ get ariaRemoved() {
601
+ return this._ariaRemoved;
602
+ }
593
603
  get htmlDiff() {
594
604
  return this._htmlDiffResult;
595
605
  }
@@ -614,5 +624,7 @@ export class Diff {
614
624
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
615
625
  this._ariaDiffResult = ariaDiff.text;
616
626
  this._ariaChangeCount = ariaDiff.count;
627
+ this._ariaAdded = ariaDiff.added;
628
+ this._ariaRemoved = ariaDiff.removed;
617
629
  }
618
630
  }
@@ -552,10 +552,11 @@ export const attachStepLogger = (target, assertionsTarget) => {
552
552
  }
553
553
  tag('step').log(step);
554
554
  };
555
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
555
+ const onPassed = (step) => listener(step);
556
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, onPassed);
556
557
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
557
558
  return () => {
558
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
559
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, onPassed);
559
560
  codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
560
561
  };
561
562
  };
@@ -630,7 +630,7 @@ class Navigator {
630
630
  const cachedVerification = actionResult.getVerification(message);
631
631
  if (cachedVerification !== null) {
632
632
  tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
633
- return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
633
+ return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
634
634
  }
635
635
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
636
636
  let experience = '';
@@ -741,9 +741,6 @@ class Navigator {
741
741
  observability: {
742
742
  agent: 'navigator',
743
743
  },
744
- catch: async (error) => {
745
- debugLog(error);
746
- },
747
744
  });
748
745
  }
749
746
  finally {
@@ -126,14 +126,6 @@ export class Pilot {
126
126
  ${sessionLog || 'No actions recorded'}
127
127
  </session_log>
128
128
 
129
- Decide and commit. "continue" extends the loop and burns iterations — choose it only when
130
- evidence is genuinely insufficient to call pass/fail, not as a safety hedge.
131
- - "pass" if final state proves the SCENARIO GOAL is accomplished. Set requestVerification.
132
- - "fail" if scenario was attempted but goal not achieved.
133
- - "skipped" if scenario is irrelevant/inapplicable, OR systematic infrastructure failures.
134
- - "continue" only when a concrete missing piece of evidence (a verify/see) would change your verdict.
135
- - Mixed evidence + final state shows success → pass. Mixed + final state unclear → continue with guidance.
136
-
137
129
  When deciding "pass", you MUST also set requestVerification to a one-sentence natural-language
138
130
  claim about the current page (e.g., "New item Foo is visible in the items list"). NOT
139
131
  code — do not write I.*, expect(), .then(), or any JavaScript. Choose the strongest single
@@ -352,7 +344,7 @@ export class Pilot {
352
344
  buildVerdictSystemPrompt(task) {
353
345
  return dedent `
354
346
  You are Pilot — final decision maker for test pass/fail. Review the evidence and commit to a
355
- verdict; "continue" only when evidence is genuinely insufficient.
347
+ verdict.
356
348
 
357
349
  ${capabilityGroundingRule}
358
350
 
@@ -366,10 +358,11 @@ export class Pilot {
366
358
  DOM assertion can't be made.
367
359
  Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
368
360
  requested action, workflow, or entity detail goal.
369
- - "fail": scenario was attempted but the goal was not achieved.
361
+ - "fail": goal not achieved and no further step toward it is available on the current page.
370
362
  - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
371
363
  crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
372
- - "continue": tester hasn't completed the goal; provide concrete guidance (which tool, what to check).
364
+ - "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
365
+ concrete missing check would change your verdict. Guidance must name that step.
373
366
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
374
367
 
375
368
  reason field: one short sentence, maximum 120 characters. Do NOT restate the decision
@@ -1030,7 +1023,9 @@ export class Pilot {
1030
1023
  ${interactive ? '- Use askUser() only as last resort.' : ''}
1031
1024
 
1032
1025
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1033
- - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1026
+ - Scenario's target control in "disabled buttons" → a precondition is unmet; identify which before acting.
1027
+ Other disabled controls often name the unsatisfied constraint; "active form" marks [required] fields.
1028
+ Aim Tester at the constraint the page names, not the one the scenario assumed — note the difference in PROGRESS.
1034
1029
  - "overlay: none" but Tester targets an overlay → overlay closed; re-trigger.
1035
1030
  - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1036
1031
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
@@ -1052,6 +1047,7 @@ export class Pilot {
1052
1047
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1053
1048
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1054
1049
  Use tool names exactly as listed. Do not invent combined names or aliases.
1050
+ Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.reloadPage() through form.
1055
1051
 
1056
1052
  ${capabilityGroundingRule}
1057
1053