explorbot 0.5.9 → 0.6.0

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 (167) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +495 -482
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/cli.ts +4 -4
  5. package/boat/api-tester/src/commands/init-command.ts +2 -0
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  7. package/boat/doc-collector/src/screenshots.ts +1 -1
  8. package/boat/prima/bin/prima-cli.ts +2 -1
  9. package/dist/bin/explorbot-cli.js +456 -444
  10. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  12. package/dist/boat/api-tester/src/cli.js +4 -4
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/models.json +1 -1
  17. package/dist/package.json +2 -1
  18. package/dist/rules/navigator/output.md +2 -8
  19. package/dist/rules/navigator/verification-actions.md +6 -2
  20. package/dist/rules/planner/ignored-pages.md +1 -0
  21. package/dist/src/action-result.js +1 -1
  22. package/dist/src/action.d.ts +0 -1
  23. package/dist/src/action.js +0 -4
  24. package/dist/src/ai/captain/idle-mode.js +42 -26
  25. package/dist/src/ai/captain/mixin.d.ts +2 -0
  26. package/dist/src/ai/captain/web-mode.js +11 -0
  27. package/dist/src/ai/captain.d.ts +6 -4
  28. package/dist/src/ai/captain.js +25 -1
  29. package/dist/src/ai/driller.js +1 -1
  30. package/dist/src/ai/experience-compactor.js +2 -2
  31. package/dist/src/ai/judge.d.ts +7 -0
  32. package/dist/src/ai/judge.js +8 -1
  33. package/dist/src/ai/navigator.d.ts +1 -0
  34. package/dist/src/ai/navigator.js +11 -7
  35. package/dist/src/ai/planner/subpages.js +1 -1
  36. package/dist/src/ai/planner.d.ts +12 -5
  37. package/dist/src/ai/planner.js +138 -86
  38. package/dist/src/ai/provider.d.ts +1 -1
  39. package/dist/src/ai/provider.js +16 -5
  40. package/dist/src/ai/rerunner.js +1 -1
  41. package/dist/src/ai/researcher/cache.js +14 -6
  42. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  43. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  44. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  45. package/dist/src/ai/researcher.js +44 -24
  46. package/dist/src/ai/rules.js +6 -7
  47. package/dist/src/ai/scout.d.ts +12 -2
  48. package/dist/src/ai/scout.js +103 -8
  49. package/dist/src/ai/tester.d.ts +9 -5
  50. package/dist/src/ai/tester.js +22 -42
  51. package/dist/src/ai/tools.js +10 -8
  52. package/dist/src/application-spec.d.ts +2 -0
  53. package/dist/src/application-spec.js +17 -12
  54. package/dist/src/commands/context-experience-command.js +0 -1
  55. package/dist/src/commands/context-knowledge-command.js +0 -1
  56. package/dist/src/commands/explore-command.d.ts +3 -2
  57. package/dist/src/commands/explore-command.js +35 -23
  58. package/dist/src/commands/help-json-command.js +3 -1
  59. package/dist/src/commands/init-command.js +8 -4
  60. package/dist/src/commands/learn-command.js +0 -2
  61. package/dist/src/commands/options/auth-option.d.ts +6 -0
  62. package/dist/src/commands/options/auth-option.js +14 -0
  63. package/dist/src/commands/options/index.d.ts +2 -0
  64. package/dist/src/commands/options/index.js +2 -0
  65. package/dist/src/commands/plan-command.d.ts +3 -0
  66. package/dist/src/commands/plan-command.js +43 -17
  67. package/dist/src/commands/plan-save-command.js +6 -17
  68. package/dist/src/commands/plans-command.js +1 -1
  69. package/dist/src/commands/test-command.js +12 -2
  70. package/dist/src/components/App.d.ts +1 -2
  71. package/dist/src/components/App.js +1 -3
  72. package/dist/src/components/InputPane.js +0 -5
  73. package/dist/src/config.d.ts +3 -2
  74. package/dist/src/config.js +3 -12
  75. package/dist/src/explorbot.d.ts +3 -2
  76. package/dist/src/explorbot.js +32 -10
  77. package/dist/src/explorer.js +6 -7
  78. package/dist/src/knowledge-tracker.d.ts +2 -2
  79. package/dist/src/knowledge-tracker.js +18 -6
  80. package/dist/src/playwright-recorder.d.ts +0 -1
  81. package/dist/src/playwright-recorder.js +0 -2
  82. package/dist/src/reporter.d.ts +1 -1
  83. package/dist/src/reporter.js +3 -3
  84. package/dist/src/test-plan.js +1 -1
  85. package/dist/src/utils/error-page.d.ts +17 -0
  86. package/dist/src/utils/error-page.js +53 -2
  87. package/dist/src/utils/html-diff.d.ts +1 -2
  88. package/dist/src/utils/html-diff.js +4 -44
  89. package/dist/src/utils/logger.js +27 -10
  90. package/dist/src/utils/markdown-terminal.js +1 -2
  91. package/dist/src/utils/next-steps.js +20 -16
  92. package/dist/src/utils/page-readiness.d.ts +2 -1
  93. package/dist/src/utils/page-readiness.js +24 -5
  94. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  95. package/dist/src/utils/test-plan-markdown.js +35 -4
  96. package/dist/src/utils/url-matcher.d.ts +1 -0
  97. package/dist/src/utils/url-matcher.js +5 -0
  98. package/docs/basics/providers.md +1 -1
  99. package/docs/contributing/regression-tests.md +19 -4
  100. package/docs/reference/commands.md +18 -2
  101. package/docs/reference/configuration.md +1 -1
  102. package/docs/web-testing/agents.md +8 -0
  103. package/docs/web-testing/customization.md +2 -0
  104. package/docs/web-testing/researcher.md +1 -1
  105. package/docs/workflow/agentic-usage.md +7 -0
  106. package/docs/workflow/knowledge.md +6 -0
  107. package/models.json +1 -1
  108. package/package.json +2 -1
  109. package/rules/navigator/output.md +2 -8
  110. package/rules/navigator/verification-actions.md +6 -2
  111. package/rules/planner/ignored-pages.md +1 -0
  112. package/src/action-result.ts +1 -2
  113. package/src/action.ts +0 -5
  114. package/src/ai/captain/idle-mode.ts +38 -26
  115. package/src/ai/captain/mixin.ts +2 -1
  116. package/src/ai/captain/web-mode.ts +12 -0
  117. package/src/ai/captain.ts +25 -7
  118. package/src/ai/driller.ts +1 -2
  119. package/src/ai/experience-compactor.ts +2 -2
  120. package/src/ai/judge.ts +10 -1
  121. package/src/ai/navigator.ts +12 -7
  122. package/src/ai/planner/subpages.ts +1 -2
  123. package/src/ai/planner.ts +140 -92
  124. package/src/ai/provider.ts +15 -6
  125. package/src/ai/rerunner.ts +1 -2
  126. package/src/ai/researcher/cache.ts +16 -7
  127. package/src/ai/researcher/deep-analysis.ts +7 -2
  128. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  129. package/src/ai/researcher.ts +44 -28
  130. package/src/ai/rules.ts +6 -7
  131. package/src/ai/scout.ts +98 -10
  132. package/src/ai/tester.ts +29 -47
  133. package/src/ai/tools.ts +11 -9
  134. package/src/application-spec.ts +19 -13
  135. package/src/commands/clean-command.ts +1 -1
  136. package/src/commands/context-command.ts +0 -1
  137. package/src/commands/context-experience-command.ts +0 -1
  138. package/src/commands/context-knowledge-command.ts +0 -1
  139. package/src/commands/explore-command.ts +36 -23
  140. package/src/commands/help-json-command.ts +8 -6
  141. package/src/commands/init-command.ts +10 -3
  142. package/src/commands/learn-command.ts +0 -2
  143. package/src/commands/options/auth-option.ts +15 -0
  144. package/src/commands/options/index.ts +2 -0
  145. package/src/commands/plan-command.ts +44 -17
  146. package/src/commands/plan-save-command.ts +6 -17
  147. package/src/commands/plans-command.ts +1 -1
  148. package/src/commands/test-command.ts +11 -2
  149. package/src/components/App.tsx +2 -6
  150. package/src/components/InputPane.tsx +0 -5
  151. package/src/config.ts +5 -16
  152. package/src/experience-tracker.ts +1 -1
  153. package/src/explorbot.ts +31 -11
  154. package/src/explorer.ts +6 -7
  155. package/src/knowledge-tracker.ts +18 -7
  156. package/src/playwright-recorder.ts +0 -2
  157. package/src/reporter.ts +3 -4
  158. package/src/test-plan.ts +1 -2
  159. package/src/utils/error-page.ts +64 -2
  160. package/src/utils/html-diff.ts +4 -56
  161. package/src/utils/html.ts +0 -1
  162. package/src/utils/logger.ts +30 -12
  163. package/src/utils/markdown-terminal.ts +1 -2
  164. package/src/utils/next-steps.ts +19 -17
  165. package/src/utils/page-readiness.ts +33 -7
  166. package/src/utils/test-plan-markdown.ts +36 -4
  167. package/src/utils/url-matcher.ts +6 -0
package/src/ai/planner.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  import dedent from 'dedent';
2
- import { z } from 'zod';
3
2
  import { ActionResult } from '../action-result.ts';
4
3
  import { setActivity } from '../activity.ts';
5
4
  import { ConfigParser } from '../config.ts';
6
5
  import type { ExperienceTracker } from '../experience-tracker.ts';
7
- import type Explorer from '../explorer.ts';
8
6
  import { Observability } from '../observability.ts';
9
7
  import type { StateManager } from '../state-manager.js';
10
8
  import { Stats } from '../stats.ts';
11
9
  import { Suite } from '../suite.ts';
12
10
  import { Plan, Test } from '../test-plan.ts';
11
+ import { PlanningBlockedError } from '../utils/error-page.ts';
13
12
  import { createDebug, tag } from '../utils/logger.js';
13
+ import { loop } from '../utils/loop.ts';
14
14
  import { jsonToTable } from '../utils/markdown-parser.ts';
15
15
  import { mdq } from '../utils/markdown-query.js';
16
- import { planToCompactAiContext } from '../utils/test-plan-markdown.ts';
16
+ import { findPlanMarkdownProblems, parsePlansFromMarkdownText, planToCompactAiContext, readPlanMarkdownComment } from '../utils/test-plan-markdown.ts';
17
17
  import type { Agent, AgentDeps } from './agent.js';
18
18
  import { Conversation } from './conversation.ts';
19
19
  import type { Fisherman } from './fisherman.ts';
@@ -26,33 +26,16 @@ import { findSimilarStateHash } from './researcher/cache.ts';
26
26
  import { hasFocusedSection } from './researcher/focus.ts';
27
27
  import { RulesLoader } from '../utils/rules-loader.ts';
28
28
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from './rules.ts';
29
- import type { Scout } from './scout.ts';
29
+ import { ScoutQuery, type Scout } from './scout.ts';
30
30
 
31
31
  const debugLog = createDebug('explorbot:planner');
32
32
 
33
- const TasksSchema = z.object({
34
- planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
35
- scenarios: z
36
- .array(
37
- z.object({
38
- scenario: z.string().describe('A single sentence describing the behavior to test.'),
39
- priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
40
- startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
41
- steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
42
- expectedOutcomes: z
43
- .array(z.string())
44
- .describe('List of expected outcomes that can be verified. Each outcome should be simple, specific, and easy to check (e.g., "Success message appears", "URL changes to /dashboard", "Form field shows error"). Keep outcomes atomic - do not combine multiple checks into one.'),
45
- })
46
- )
47
- .describe('List of testing scenarios'),
48
- notPlannableReason: z.string().nullish().describe('When scenarios is empty, one sentence saying why this page has nothing to test; otherwise null.'),
49
- });
33
+ const MAX_FORMAT_ATTEMPTS = 3;
50
34
 
51
35
  const PlannerBase = WithSessionDedup(WithSubPages(Object as unknown as new (...args: any[]) => object));
52
36
 
53
37
  export class Planner extends PlannerBase implements Agent {
54
38
  emoji = '📋';
55
- private explorer: Explorer;
56
39
  provider: Provider;
57
40
  stateManager: StateManager;
58
41
  private experienceTracker: ExperienceTracker;
@@ -60,6 +43,8 @@ export class Planner extends PlannerBase implements Agent {
60
43
 
61
44
  MIN_TASKS = 3;
62
45
  MAX_TASKS = 12;
46
+ private minTasksOverride?: number;
47
+ private maxTasksOverride?: number;
63
48
  currentPlan: Plan | null = null;
64
49
  freshStart = false;
65
50
  private lastStyleName = '';
@@ -70,7 +55,6 @@ export class Planner extends PlannerBase implements Agent {
70
55
 
71
56
  constructor(deps: AgentDeps, researcher: Researcher) {
72
57
  super();
73
- this.explorer = deps.explorer;
74
58
  this.provider = deps.ai;
75
59
  this.researcher = researcher;
76
60
  this.stateManager = deps.stateManager;
@@ -95,8 +79,23 @@ export class Planner extends PlannerBase implements Agent {
95
79
  return Math.max(0, Math.min(100, value));
96
80
  }
97
81
 
98
- private getDefaultStartUrl(state: { url: string; fullUrl?: string }): string {
99
- return state.fullUrl || state.url;
82
+ private get effectiveMinTasks(): number {
83
+ if (this.minTasksOverride !== undefined) return this.minTasksOverride;
84
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.minTests ?? this.MIN_TASKS;
85
+ }
86
+
87
+ private get effectiveMaxTasks(): number {
88
+ if (this.maxTasksOverride !== undefined) return this.maxTasksOverride;
89
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.maxTests ?? this.MAX_TASKS;
90
+ }
91
+
92
+ setTestCount(opts: { minTests?: number; maxTests?: number }): void {
93
+ if (opts.minTests !== undefined) this.minTasksOverride = opts.minTests;
94
+ if (opts.maxTests !== undefined) this.maxTasksOverride = opts.maxTests;
95
+ }
96
+
97
+ private getDefaultStartUrl(state: { url: string }): string {
98
+ return state.url;
100
99
  }
101
100
 
102
101
  getSystemMessage(feature?: string): string {
@@ -150,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
150
149
  return getRegisteredPlan(url)?.plan || null;
151
150
  }
152
151
 
153
- async plan(feature?: string, style?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<Plan> {
152
+ async plan(feature?: string, style?: string, parentPlan?: Plan): Promise<Plan> {
154
153
  Stats.plans++;
155
154
  const state = this.stateManager.getCurrentState();
156
155
  debugLog('Planning:', state?.url);
@@ -204,22 +203,42 @@ export class Planner extends PlannerBase implements Agent {
204
203
  tag('step').log('Focusing on main content of this page');
205
204
  }
206
205
 
207
- debugLog('Sending planning prompt to AI provider with structured output');
208
-
209
- const aiResult = await this.provider.generateObject(conversation.messages, TasksSchema, conversation.model);
206
+ debugLog('Sending planning prompt to AI provider');
207
+
208
+ let markdown = '';
209
+ await loop(
210
+ async ({ stop, iteration }) => {
211
+ const response = await this.provider.chat(conversation.messages, conversation.model, { agentName: 'planner' });
212
+ markdown = response.text;
213
+ const problems = findPlanMarkdownProblems(markdown);
214
+ if (problems.length === 0) stop();
215
+ if (iteration >= MAX_FORMAT_ATTEMPTS) throw new Error(`Planner output does not match the plan format: ${problems.join('; ')}`);
216
+ debugLog('Plan format problems: %o', problems);
217
+ conversation.addAssistantText(markdown);
218
+ conversation.addUserText(dedent`
219
+ Your plan does not match the required format:
220
+ ${problems.map((problem) => `- ${problem}`).join('\n')}
221
+ Rewrite the whole plan in the format from <output_format>. Keep the same scenarios; fix only the structure.
222
+ `);
223
+ },
224
+ { maxAttempts: MAX_FORMAT_ATTEMPTS, interruptible: false }
225
+ );
210
226
 
211
- if (!aiResult?.object?.scenarios) {
212
- throw new Error('No tasks were created successfully');
227
+ const defaultStartUrl = this.getDefaultStartUrl(state);
228
+ markdown = mdq(markdown).query('h1').first().insertAfter(`### Prerequisite\n\n* URL: ${defaultStartUrl}`).toString();
229
+ const parsedPlan = parsePlansFromMarkdownText(markdown)[0];
230
+ const fromPlanning = parsedPlan?.tests || [];
231
+ const blockedBy = readPlanMarkdownComment(markdown, 'blocked');
232
+ const prerequisite = readPlanMarkdownComment(markdown, 'prerequisite');
233
+
234
+ if (!this.currentPlan && fromPlanning.length === 0 && blockedBy) {
235
+ tag('warning').log(`Page is not plannable: ${blockedBy}`);
213
236
  }
214
-
215
- if (!this.currentPlan && aiResult.object.scenarios.length === 0 && aiResult.object.notPlannableReason) {
216
- tag('warning').log(`Page is not plannable: ${aiResult.object.notPlannableReason}`);
237
+ if (!this.currentPlan && fromPlanning.length === 0 && prerequisite) {
238
+ throw new PlanningBlockedError(state.url, prerequisite, feature, style);
217
239
  }
218
240
 
219
- const defaultStartUrl = this.getDefaultStartUrl(state);
220
- const fromPlanning = aiResult.object.scenarios.map((s: any) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
221
-
222
- return { tests: fromPlanning, planName: aiResult.object.planName };
241
+ return { tests: fromPlanning, planName: parsedPlan?.title };
223
242
  });
224
243
 
225
244
  const tests = result.tests;
@@ -345,9 +364,6 @@ export class Planner extends PlannerBase implements Agent {
345
364
  private async buildConversation(state: ActionResult, style?: string, parentPlan?: Plan, feature?: string): Promise<Conversation> {
346
365
  const model = this.provider.getAgenticModel('planner');
347
366
  const conversation = new Conversation([], model);
348
- conversation.autoTrimTag('page_research', 20000);
349
- conversation.autoTrimTag('tested_scenarios', 10000);
350
- conversation.autoTrimTag('docs_context', 8000);
351
367
 
352
368
  conversation.addUserText(this.getSystemMessage(feature));
353
369
 
@@ -380,23 +396,25 @@ export class Planner extends PlannerBase implements Agent {
380
396
  If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
381
397
  If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
382
398
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
383
- If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
384
- If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
399
+ If a scenario needs existing records or other target data, propose using api if <api_data_preparation> section is set or reuired record is visible on page or can be created via UI.
400
+ If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks UI controls skip that page.
385
401
  Do not assume hidden data exists just because a control is present.
386
402
  Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
387
403
  If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
388
- Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
389
- For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
390
- Detail-view scenarios must target visible data entities from list rows, cards, tree nodes, or detail links; do not use filter tabs, counters, status tabs, breadcrumbs, or navigation controls as detail targets.
391
- DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
404
+
405
+ <meaningful_tests>
392
406
  Every test must complete a meaningful action that changes application state or produces a business outcome.
407
+ DO NOT propose "verification-only" tests that have only assertions and no real state change
393
408
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
394
409
  Clicking a dropdown is NOT a test — selecting an option and verifying the result IS a test.
410
+ </meaningful_tests>
411
+
395
412
  <priority_order>
396
413
  Tests that change application data are MORE valuable than tests that only change the UI display.
397
414
  Prioritize interactions that create, update, or delete data — these test real application behavior.
398
415
  Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
399
416
  If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
417
+ If <docs_content> is provided make sure you incorporate app knowledge into planning
400
418
  </priority_order>
401
419
  ${capabilityGroundingRule}
402
420
  ${dataProtectionRules}
@@ -406,28 +424,8 @@ export class Planner extends PlannerBase implements Agent {
406
424
 
407
425
  conversation.addUserText(planningPrompt);
408
426
  conversation.addUserText(this.getTasksMessage());
409
- const currentState = this.stateManager.getCurrentState();
410
- const research = await this.researcher.research(currentState || state, {
411
- deep: true,
412
- });
413
427
 
414
- let docsPromise: Promise<string> | null = null;
415
- if (this.scout && this.docsWeight > 0) {
416
- docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
417
- }
418
- let plannerResearch = mdq(research).query('code').replace('').toString();
419
- plannerResearch = mdq(plannerResearch)
420
- .query('table')
421
- .replaceEach((table) => {
422
- const rows = table.toJson();
423
- if (rows.length === 0 || !rows[0].Element) return table.text();
424
- const elementWithType = rows.map((r) => ({
425
- Element: r.Element,
426
- Type: r.Type || '',
427
- }));
428
- return jsonToTable(elementWithType, ['Element', 'Type']);
429
- })
430
- .toString();
428
+ const [plannerResearch, docs] = await Promise.all([this.collectResearch(state), this.collectDocs(state, feature)]);
431
429
 
432
430
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
433
431
  const focusNote = hasFocusedOverlay ? "IMPORTANT: One section is marked as **Focused** — this is the user's current focus area. Concentrate testing on the Focused section FIRST — test all interactions inside it before planning tests for the rest of the page." : '';
@@ -448,22 +446,23 @@ export class Planner extends PlannerBase implements Agent {
448
446
  const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
449
447
  if (applicationContext) {
450
448
  conversation.addUserText(applicationContext);
449
+ const words = applicationContext.split(/\s+/).filter(Boolean).length;
450
+ tag('info').log(`Added application spec to planning: ${words} words for ${state.url}`);
451
451
  }
452
452
 
453
- if (docsPromise) {
454
- const docs = await docsPromise;
455
- if (docs) {
456
- conversation.addUserText(dedent`
457
- <docs_context>
458
- Documentation retrieved from the collected corpus by the Scout agent.
459
- Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
453
+ if (docs) {
454
+ conversation.addUserText(dedent`
455
+ <docs_context>
456
+ Documentation retrieved from the collected corpus by the Scout agent.
457
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
460
458
 
461
- Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
459
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
462
460
 
463
- ${docs}
464
- </docs_context>
465
- `);
466
- }
461
+ ${docs}
462
+ </docs_context>
463
+ `);
464
+ const words = docs.split(/\s+/).filter(Boolean).length;
465
+ tag('info').log(`Added spec to planning (${words} words)`);
467
466
  }
468
467
 
469
468
  let activeRegion = '';
@@ -638,20 +637,14 @@ export class Planner extends PlannerBase implements Agent {
638
637
  private getTasksMessage(): string {
639
638
  return dedent`
640
639
  <task>
641
- Provide testing scenarios as structured data with the following requirements:
640
+ Provide testing scenarios with the following requirements:
642
641
  1. Create a short, descriptive plan name that summarizes what will be tested (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")
643
- 2. Assign priorities based on business importance:
644
- - CRITICAL: Core business functionality that defines the page purpose
645
- - IMPORTANT: Key user flows, primary features, CRUD operations
646
- - HIGH: Secondary features, edge cases for critical flows
647
- - NORMAL: Supporting actions, settings, configuration
648
- - LOW: Cosmetic checks, boundary testing, minor interactions
649
- 3. Propose tests following research section order: ${this.sectionOrder.join(', ')}
642
+ 2. Propose tests following research section order: ${this.sectionOrder.join(', ')}
650
643
  Cover sections in this order — first propose tests for elements from earlier sections, then later ones.
651
644
  Extended Research sections come after the main sections.
652
- 4. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
653
- 5. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
654
- 6. For each task, provide BOTH steps and expected outcomes as separate arrays:
645
+ 3. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
646
+ 4. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
647
+ 5. For each task, provide BOTH steps and expected outcomes as separate lists:
655
648
 
656
649
  STEPS - actionable commands:
657
650
  - Each step should be a specific action to perform
@@ -682,8 +675,63 @@ export class Planner extends PlannerBase implements Agent {
682
675
  - Do not wrap text in ** or * quotes, ( or ) brackets.
683
676
  - Avoid using emojis or special characters.
684
677
  7. Only tests that can be tested from web UI should be proposed.
685
- 8. For a fresh plan propose at least ${this.MIN_TASKS} tests; when expanding an existing plan return ONLY new scenarios not in the tested list, and an empty array if none remain.
678
+ 8. For a fresh plan propose at least ${this.effectiveMinTasks} and at most ${this.effectiveMaxTasks} tests; when expanding an existing plan return ONLY new scenarios not in the tested list, and no scenarios if none remain.
686
679
  </task>
680
+
681
+ <output_format>
682
+ Reply with the plan as markdown in exactly this format and nothing else:
683
+
684
+ <!-- suite -->
685
+ # {short plan name summarizing what is tested}
686
+
687
+ <!-- test -->
688
+ # {one sentence describing the behavior to test}
689
+
690
+ ## Steps
691
+ * {first action}
692
+ * {next action}
693
+ * {one bullet per action, as many as the workflow needs}
694
+
695
+ ## Expected
696
+ * {first verifiable outcome}
697
+ * {one bullet per outcome, as many as the scenario verifies}
698
+
699
+ Repeat the block from <!-- test to ## Expected for every scenario.
700
+ Every scenario needs its own <!-- test --> comment, its steps and its expected outcomes.
701
+ Steps list every action of the workflow in order, one action per bullet, from the starting page to the final action.
702
+ Expected lists every outcome to verify, one outcome per bullet.
703
+
704
+ When there is nothing to test, write no scenarios; after the plan name add one comment instead:
705
+ <!-- prerequisite: {step that must be done first} --> when the page shows a sign-in form or another required step instead of the content its URL is for
706
+ <!-- blocked: {one sentence why the page has nothing to test} --> otherwise
707
+ </output_format>
687
708
  `;
688
709
  }
710
+
711
+ private async collectResearch(state: ActionResult): Promise<string> {
712
+ const currentState = this.stateManager.getCurrentState();
713
+ const research = await this.researcher.research(currentState || state, { deep: true });
714
+ let plannerResearch = mdq(research).query('code').replace('').toString();
715
+ plannerResearch = mdq(plannerResearch)
716
+ .query('table')
717
+ .replaceEach((table) => {
718
+ const rows = table.toJson();
719
+ if (rows.length === 0 || !rows[0].Element) return table.text();
720
+ const elementWithType = rows.map((r) => ({
721
+ Element: r.Element,
722
+ Type: r.Type || '',
723
+ }));
724
+ return jsonToTable(elementWithType, ['Element', 'Type']);
725
+ })
726
+ .toString();
727
+ return plannerResearch;
728
+ }
729
+
730
+ private async collectDocs(state: ActionResult, feature?: string): Promise<string> {
731
+ if (!this.scout || this.docsWeight <= 0) return '';
732
+ const query = ScoutQuery.fromState(state);
733
+ query.feature = feature;
734
+ query.exclude = this.knowledgeTracker.applicationSpecUrls(state);
735
+ return this.scout.collectDocs(query);
736
+ }
689
737
  }
@@ -155,14 +155,23 @@ export class Provider {
155
155
  this.judge = judge;
156
156
  }
157
157
 
158
- async oracle(statement: string, state: unknown, options: any = {}): Promise<boolean> {
159
- const judged = await this.judge?.assert(statement, state);
158
+ async oracle(statement: string, state: unknown, options: any = {}): Promise<boolean | null> {
159
+ const { screenshot, ...generateOptions } = options;
160
+ let judgeState = state;
161
+ if (screenshot) judgeState = { ...(state as object), screenshot: `data:image/png;base64,${screenshot.toString('base64')}` };
162
+ const judged = await this.judge?.assert(statement, judgeState);
160
163
  if (typeof judged === 'boolean') return judged;
161
164
 
162
165
  const schema = z.object({ holds: z.boolean().describe('whether the statement is true') });
163
- const content = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
164
- const response = await this.generateObject([{ role: 'user' as const, content }], schema, this.getAgenticModel(options.agentName), options).catch(() => null);
165
- return response?.object?.holds === true;
166
+ let content: any = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
167
+ if (screenshot)
168
+ content = [
169
+ { type: 'text', text: content },
170
+ { type: 'file', mediaType: 'image/png', data: screenshot },
171
+ ];
172
+ const response = await this.generateObject([{ role: 'user' as const, content }], schema, this.getAgenticModel(generateOptions.agentName), generateOptions).catch(() => null);
173
+ if (!response) return null;
174
+ return response.object?.holds === true;
166
175
  }
167
176
 
168
177
  getAgenticModel(agentName?: string): any {
@@ -520,7 +529,7 @@ export class Provider {
520
529
  // Log tool usage summary
521
530
  if (response.toolCalls && response.toolCalls.length > 0) {
522
531
  responseLog(response.toolCalls);
523
- response.toolCalls.forEach((call: any, index: number) => {
532
+ response.toolCalls.forEach((call: any) => {
524
533
  tag('debug').log(`${call.toolName} > ${Object.values(call?.input || []).join(', ')}`);
525
534
  });
526
535
  }
@@ -23,7 +23,6 @@ import { ensureCodeceptResolvable } from '../utils/test-files.ts';
23
23
  import type { Agent, AgentDeps } from './agent.ts';
24
24
  import { toolExecutionLabel } from './conversation.ts';
25
25
  import type { Navigator } from './navigator.ts';
26
- import { Provider } from './provider.ts';
27
26
  import { actionRule, locatorRule, sectionContextRule } from './rules.ts';
28
27
  import { TaskAgent } from './task-agent.ts';
29
28
  import { createCodeceptJSTools, withdrawVisionTools } from './tools.ts';
@@ -300,7 +299,7 @@ export class Rerunner extends TaskAgent implements Agent {
300
299
  const failedCode = step.toCode?.() || '';
301
300
  console.log(chalk.yellow(` ${figureSet.arrowRight} Healing: ${failedCode}`));
302
301
 
303
- return async ({ I }: { I: any }) => {
302
+ return async () => {
304
303
  const bashTool = await createBashTool({
305
304
  destination: this.traceDir,
306
305
  onBeforeBashCall: ({ command }) => {
@@ -15,6 +15,7 @@ const SIMILARITY_THRESHOLD = 90;
15
15
  const memoryCache = new TTLCache<string>(CACHE_TTL_MS);
16
16
 
17
17
  let fingerprintWorker: Worker | null = null;
18
+ let fingerprintRequestId = 0;
18
19
 
19
20
  export function researchPath(hash: string): string {
20
21
  return outputPath('research', `${hash}.md`);
@@ -90,14 +91,13 @@ function findSimilarMatch(combinedHtml: string, url?: string): Promise<Fingerpri
90
91
 
91
92
  const worker = getFingerprintWorker();
92
93
 
93
- return new Promise((resolve) => {
94
- const timeout = setTimeout(() => {
95
- debugLog('Fingerprint worker timed out');
96
- resolve(null);
97
- }, FINGERPRINT_WORKER_TIMEOUT_MS);
94
+ const id = ++fingerprintRequestId;
98
95
 
99
- worker.on('message', (data: { matchHash: string | null; similarity: number; url?: string }) => {
96
+ return new Promise((resolve) => {
97
+ const onMessage = (data: { id: number; matchHash: string | null; similarity: number; url?: string }) => {
98
+ if (data.id !== id) return;
100
99
  clearTimeout(timeout);
100
+ worker.off('message', onMessage);
101
101
  const { matchHash, similarity } = data;
102
102
  if (!matchHash) {
103
103
  resolve(null);
@@ -106,9 +106,18 @@ function findSimilarMatch(combinedHtml: string, url?: string): Promise<Fingerpri
106
106
 
107
107
  debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
108
108
  resolve({ hash: matchHash, similarity, url: data.url });
109
- });
109
+ };
110
+
111
+ const timeout = setTimeout(() => {
112
+ worker.off('message', onMessage);
113
+ debugLog('Fingerprint worker timed out');
114
+ resolve(null);
115
+ }, FINGERPRINT_WORKER_TIMEOUT_MS);
116
+
117
+ worker.on('message', onMessage);
110
118
 
111
119
  worker.postMessage({
120
+ id,
112
121
  html: combinedHtml,
113
122
  statesDir,
114
123
  maxAgeMs: FINGERPRINT_MAX_AGE_MS,
@@ -26,6 +26,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
26
26
  declare stateManager: StateManager;
27
27
  declare config: ExplorbotConfig;
28
28
  declare actionResult: ActionResult | undefined;
29
+ researchedOverlays = new Set<string>();
29
30
 
30
31
  async performDeepAnalysis(state: WebPageState, result: ResearchResult): Promise<void> {
31
32
  tag('info').log('Starting deep analysis of expandable elements');
@@ -54,7 +55,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
54
55
  }
55
56
  }
56
57
 
57
- let expandables = await this._discoverExpandables(result.text, missing, verifiedCodes);
58
+ let expandables = await this._discoverExpandables(result.text, missing);
58
59
  if (expandables.length > maxClicks) {
59
60
  expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
60
61
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
@@ -93,6 +94,10 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
93
94
  const region = current.overlay;
94
95
  if (!region.isOpen || !region.name) return null;
95
96
 
97
+ const overlayKey = `${pageStateHash}:${region.name}`;
98
+ if (this.researchedOverlays.has(overlayKey)) return null;
99
+ this.researchedOverlays.add(overlayKey);
100
+
96
101
  const cached = getCachedResearch(pageStateHash);
97
102
  if (!cached) return null;
98
103
 
@@ -199,7 +204,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
199
204
  }
200
205
  }
201
206
 
202
- private async _discoverExpandables(researchText: string, missing: PreviousSection[] = [], verifiedCodes: string[] = []): Promise<ExpandableElement[]> {
207
+ private async _discoverExpandables(researchText: string, missing: PreviousSection[] = []): Promise<ExpandableElement[]> {
203
208
  const allElements = new Map<string, ExpandableElement>();
204
209
  for (const section of parseResearchSections(researchText)) {
205
210
  for (const el of section.elements) {
@@ -6,16 +6,16 @@ import { isSamePageFamily } from '../../utils/url-matcher.ts';
6
6
  import { diceSimilarity } from '../../utils/similarity.ts';
7
7
 
8
8
  parentPort!.on('message', (data: FingerprintRequest) => {
9
- const { html, statesDir, maxAgeMs, threshold, url } = data;
9
+ const { id, html, statesDir, maxAgeMs, threshold, url } = data;
10
10
 
11
11
  if (!existsSync(statesDir)) {
12
- parentPort!.postMessage({ matchHash: null, similarity: 0 });
12
+ parentPort!.postMessage({ id, matchHash: null, similarity: 0 });
13
13
  return;
14
14
  }
15
15
 
16
16
  const currentFingerprint = new Set(computeHtmlFingerprint(html));
17
17
  if (currentFingerprint.size === 0) {
18
- parentPort!.postMessage({ matchHash: null, similarity: 0 });
18
+ parentPort!.postMessage({ id, matchHash: null, similarity: 0 });
19
19
  return;
20
20
  }
21
21
 
@@ -44,7 +44,7 @@ parentPort!.on('message', (data: FingerprintRequest) => {
44
44
  }
45
45
 
46
46
  const matched = bestSimilarity >= threshold;
47
- parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
47
+ parentPort!.postMessage({ id, matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
48
48
  });
49
49
 
50
50
  function readFingerprint(filePath: string): FingerprintRecord {
@@ -59,4 +59,4 @@ function readFingerprint(filePath: string): FingerprintRecord {
59
59
  }
60
60
 
61
61
  type FingerprintRecord = { entries: string[]; url?: string };
62
- type FingerprintRequest = { html: string; statesDir: string; maxAgeMs: number; threshold: number; url?: string };
62
+ type FingerprintRequest = { id: number; html: string; statesDir: string; maxAgeMs: number; threshold: number; url?: string };