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
@@ -1,6 +1,5 @@
1
1
  import { ActionResult } from '../action-result.js';
2
2
  import type { ExperienceTracker } from '../experience-tracker.js';
3
- import type Explorer from '../explorer.js';
4
3
  import type { StateManager } from '../state-manager.js';
5
4
  import { Suite } from '../suite.js';
6
5
  import { Plan, Test } from '../test-plan.js';
@@ -9,7 +8,7 @@ import { Conversation } from './conversation.js';
9
8
  import type { Fisherman } from './fisherman.js';
10
9
  import type { Provider } from './provider.js';
11
10
  import { type Researcher } from './researcher.js';
12
- import type { Scout } from './scout.js';
11
+ import { type Scout } from './scout.js';
13
12
  declare const PlannerBase: {
14
13
  new (...args: any[]): {
15
14
  currentPlan: Plan | null;
@@ -47,13 +46,14 @@ declare const PlannerBase: {
47
46
  } & (new (...args: any[]) => object);
48
47
  export declare class Planner extends PlannerBase implements Agent {
49
48
  emoji: string;
50
- explorer: Explorer;
51
49
  provider: Provider;
52
50
  stateManager: StateManager;
53
51
  experienceTracker: ExperienceTracker;
54
52
  knowledgeTracker: AgentDeps['knowledgeTracker'];
55
53
  MIN_TASKS: number;
56
54
  MAX_TASKS: number;
55
+ minTasksOverride?: number;
56
+ maxTasksOverride?: number;
57
57
  currentPlan: Plan | null;
58
58
  freshStart: boolean;
59
59
  lastStyleName: string;
@@ -66,14 +66,19 @@ export declare class Planner extends PlannerBase implements Agent {
66
66
  setScout(scout: Scout): void;
67
67
  get sectionOrder(): string[];
68
68
  get docsWeight(): number;
69
+ get effectiveMinTasks(): number;
70
+ get effectiveMaxTasks(): number;
71
+ setTestCount(opts: {
72
+ minTests?: number;
73
+ maxTests?: number;
74
+ }): void;
69
75
  getDefaultStartUrl(state: {
70
76
  url: string;
71
- fullUrl?: string;
72
77
  }): string;
73
78
  getSystemMessage(feature?: string): string;
74
79
  setPlan(plan: Plan): void;
75
80
  static getCachedPlan(url: string): Plan | null;
76
- plan(feature?: string, style?: string, parentPlan?: Plan, completedPlans?: Plan[]): Promise<Plan>;
81
+ plan(feature?: string, style?: string, parentPlan?: Plan): Promise<Plan>;
77
82
  getSuite(): Suite | null;
78
83
  addNewTests(tests: Test[], defaultStartUrl: string): Test[];
79
84
  getExistingTestFileScenarios(currentUrl?: string): Set<string>;
@@ -81,5 +86,7 @@ export declare class Planner extends PlannerBase implements Agent {
81
86
  buildApproach(style?: string): string;
82
87
  buildConversation(state: ActionResult, style?: string, parentPlan?: Plan, feature?: string): Promise<Conversation>;
83
88
  getTasksMessage(): string;
89
+ collectResearch(state: ActionResult): Promise<string>;
90
+ collectDocs(state: ActionResult, feature?: string): Promise<string>;
84
91
  }
85
92
  export {};
@@ -1,16 +1,17 @@
1
1
  import dedent from 'dedent';
2
- import { z } from 'zod';
3
2
  import { ActionResult } from "../action-result.js";
4
3
  import { setActivity } from "../activity.js";
5
4
  import { ConfigParser } from "../config.js";
6
5
  import { Observability } from "../observability.js";
7
6
  import { Stats } from "../stats.js";
8
7
  import { Suite } from "../suite.js";
9
- import { Plan, Test } from "../test-plan.js";
8
+ import { Plan } from "../test-plan.js";
9
+ import { PlanningBlockedError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from '../utils/logger.js';
11
+ import { loop } from "../utils/loop.js";
11
12
  import { jsonToTable } from "../utils/markdown-parser.js";
12
13
  import { mdq } from '../utils/markdown-query.js';
13
- import { planToCompactAiContext } from "../utils/test-plan-markdown.js";
14
+ import { findPlanMarkdownProblems, parsePlansFromMarkdownText, planToCompactAiContext, readPlanMarkdownComment } from "../utils/test-plan-markdown.js";
14
15
  import { Conversation } from "./conversation.js";
15
16
  import { WithSessionDedup } from "./planner/session-dedup.js";
16
17
  import { getActiveStyle } from "./planner/styles.js";
@@ -20,32 +21,20 @@ import { findSimilarStateHash } from "./researcher/cache.js";
20
21
  import { hasFocusedSection } from "./researcher/focus.js";
21
22
  import { RulesLoader } from "../utils/rules-loader.js";
22
23
  import { capabilityGroundingRule, dataProtectionRules, fileUploadRule } from "./rules.js";
24
+ import { ScoutQuery } from "./scout.js";
23
25
  const debugLog = createDebug('explorbot:planner');
24
- const TasksSchema = z.object({
25
- planName: z.string().describe('Short descriptive name for the test plan (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")'),
26
- scenarios: z
27
- .array(z.object({
28
- scenario: z.string().describe('A single sentence describing the behavior to test.'),
29
- priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
30
- 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.'),
31
- 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.'),
32
- expectedOutcomes: z
33
- .array(z.string())
34
- .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.'),
35
- }))
36
- .describe('List of testing scenarios'),
37
- notPlannableReason: z.string().nullish().describe('When scenarios is empty, one sentence saying why this page has nothing to test; otherwise null.'),
38
- });
26
+ const MAX_FORMAT_ATTEMPTS = 3;
39
27
  const PlannerBase = WithSessionDedup(WithSubPages(Object));
40
28
  export class Planner extends PlannerBase {
41
29
  emoji = '📋';
42
- explorer;
43
30
  provider;
44
31
  stateManager;
45
32
  experienceTracker;
46
33
  knowledgeTracker;
47
34
  MIN_TASKS = 3;
48
35
  MAX_TASKS = 12;
36
+ minTasksOverride;
37
+ maxTasksOverride;
49
38
  currentPlan = null;
50
39
  freshStart = false;
51
40
  lastStyleName = '';
@@ -55,7 +44,6 @@ export class Planner extends PlannerBase {
55
44
  scout = null;
56
45
  constructor(deps, researcher) {
57
46
  super();
58
- this.explorer = deps.explorer;
59
47
  this.provider = deps.ai;
60
48
  this.researcher = researcher;
61
49
  this.stateManager = deps.stateManager;
@@ -75,8 +63,24 @@ export class Planner extends PlannerBase {
75
63
  const value = ConfigParser.getInstance().getConfig().ai?.agents?.planner?.docsWeight ?? 70;
76
64
  return Math.max(0, Math.min(100, value));
77
65
  }
66
+ get effectiveMinTasks() {
67
+ if (this.minTasksOverride !== undefined)
68
+ return this.minTasksOverride;
69
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.minTests ?? this.MIN_TASKS;
70
+ }
71
+ get effectiveMaxTasks() {
72
+ if (this.maxTasksOverride !== undefined)
73
+ return this.maxTasksOverride;
74
+ return ConfigParser.getInstance().getConfig().ai?.agents?.planner?.maxTests ?? this.MAX_TASKS;
75
+ }
76
+ setTestCount(opts) {
77
+ if (opts.minTests !== undefined)
78
+ this.minTasksOverride = opts.minTests;
79
+ if (opts.maxTests !== undefined)
80
+ this.maxTasksOverride = opts.maxTests;
81
+ }
78
82
  getDefaultStartUrl(state) {
79
- return state.fullUrl || state.url;
83
+ return state.url;
80
84
  }
81
85
  getSystemMessage(feature) {
82
86
  const currentUrl = this.stateManager.getCurrentState()?.url;
@@ -126,7 +130,7 @@ export class Planner extends PlannerBase {
126
130
  static getCachedPlan(url) {
127
131
  return getRegisteredPlan(url)?.plan || null;
128
132
  }
129
- async plan(feature, style, parentPlan, completedPlans) {
133
+ async plan(feature, style, parentPlan) {
130
134
  Stats.plans++;
131
135
  const state = this.stateManager.getCurrentState();
132
136
  debugLog('Planning:', state?.url);
@@ -175,17 +179,37 @@ export class Planner extends PlannerBase {
175
179
  else {
176
180
  tag('step').log('Focusing on main content of this page');
177
181
  }
178
- debugLog('Sending planning prompt to AI provider with structured output');
179
- const aiResult = await this.provider.generateObject(conversation.messages, TasksSchema, conversation.model);
180
- if (!aiResult?.object?.scenarios) {
181
- throw new Error('No tasks were created successfully');
182
+ debugLog('Sending planning prompt to AI provider');
183
+ let markdown = '';
184
+ await loop(async ({ stop, iteration }) => {
185
+ const response = await this.provider.chat(conversation.messages, conversation.model, { agentName: 'planner' });
186
+ markdown = response.text;
187
+ const problems = findPlanMarkdownProblems(markdown);
188
+ if (problems.length === 0)
189
+ stop();
190
+ if (iteration >= MAX_FORMAT_ATTEMPTS)
191
+ throw new Error(`Planner output does not match the plan format: ${problems.join('; ')}`);
192
+ debugLog('Plan format problems: %o', problems);
193
+ conversation.addAssistantText(markdown);
194
+ conversation.addUserText(dedent `
195
+ Your plan does not match the required format:
196
+ ${problems.map((problem) => `- ${problem}`).join('\n')}
197
+ Rewrite the whole plan in the format from <output_format>. Keep the same scenarios; fix only the structure.
198
+ `);
199
+ }, { maxAttempts: MAX_FORMAT_ATTEMPTS, interruptible: false });
200
+ const defaultStartUrl = this.getDefaultStartUrl(state);
201
+ markdown = mdq(markdown).query('h1').first().insertAfter(`### Prerequisite\n\n* URL: ${defaultStartUrl}`).toString();
202
+ const parsedPlan = parsePlansFromMarkdownText(markdown)[0];
203
+ const fromPlanning = parsedPlan?.tests || [];
204
+ const blockedBy = readPlanMarkdownComment(markdown, 'blocked');
205
+ const prerequisite = readPlanMarkdownComment(markdown, 'prerequisite');
206
+ if (!this.currentPlan && fromPlanning.length === 0 && blockedBy) {
207
+ tag('warning').log(`Page is not plannable: ${blockedBy}`);
182
208
  }
183
- if (!this.currentPlan && aiResult.object.scenarios.length === 0 && aiResult.object.notPlannableReason) {
184
- tag('warning').log(`Page is not plannable: ${aiResult.object.notPlannableReason}`);
209
+ if (!this.currentPlan && fromPlanning.length === 0 && prerequisite) {
210
+ throw new PlanningBlockedError(state.url, prerequisite, feature, style);
185
211
  }
186
- const defaultStartUrl = this.getDefaultStartUrl(state);
187
- const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
188
- return { tests: fromPlanning, planName: aiResult.object.planName };
212
+ return { tests: fromPlanning, planName: parsedPlan?.title };
189
213
  });
190
214
  const tests = result.tests;
191
215
  debugLog('Created tests:', tests);
@@ -304,9 +328,6 @@ export class Planner extends PlannerBase {
304
328
  async buildConversation(state, style, parentPlan, feature) {
305
329
  const model = this.provider.getAgenticModel('planner');
306
330
  const conversation = new Conversation([], model);
307
- conversation.autoTrimTag('page_research', 20000);
308
- conversation.autoTrimTag('tested_scenarios', 10000);
309
- conversation.autoTrimTag('docs_context', 8000);
310
331
  conversation.addUserText(this.getSystemMessage(feature));
311
332
  let ignoredPagesRule = '';
312
333
  if (!feature)
@@ -337,23 +358,25 @@ export class Planner extends PlannerBase {
337
358
  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.
338
359
  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.
339
360
  Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
340
- 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.
341
- 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.
361
+ 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.
362
+ If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks UI controls skip that page.
342
363
  Do not assume hidden data exists just because a control is present.
343
364
  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.
344
365
  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.
345
- 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.
346
- 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.
347
- 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.
348
- DO NOT propose "verification-only" tests that merely open a UI element (modal, dropdown, panel) and check it exists.
366
+
367
+ <meaningful_tests>
349
368
  Every test must complete a meaningful action that changes application state or produces a business outcome.
369
+ DO NOT propose "verification-only" tests that have only assertions and no real state change
350
370
  Opening a modal is NOT a test — performing an action INSIDE the modal IS a test.
351
371
  Clicking a dropdown is NOT a test — selecting an option and verifying the result IS a test.
372
+ </meaningful_tests>
373
+
352
374
  <priority_order>
353
375
  Tests that change application data are MORE valuable than tests that only change the UI display.
354
376
  Prioritize interactions that create, update, or delete data — these test real application behavior.
355
377
  Tests that only switch views, toggle filters, or paginate are LESS valuable — propose them only after data-changing tests are covered.
356
378
  If multiple ways to create or modify data exist (different types, different forms), propose a separate test for each.
379
+ If <docs_content> is provided make sure you incorporate app knowledge into planning
357
380
  </priority_order>
358
381
  ${capabilityGroundingRule}
359
382
  ${dataProtectionRules}
@@ -362,28 +385,7 @@ export class Planner extends PlannerBase {
362
385
  `;
363
386
  conversation.addUserText(planningPrompt);
364
387
  conversation.addUserText(this.getTasksMessage());
365
- const currentState = this.stateManager.getCurrentState();
366
- const research = await this.researcher.research(currentState || state, {
367
- deep: true,
368
- });
369
- let docsPromise = null;
370
- if (this.scout && this.docsWeight > 0) {
371
- docsPromise = this.scout.collectDocs({ url: state.url, title: state.title, feature, excludeUrls: this.knowledgeTracker.applicationSpecUrls(state) });
372
- }
373
- let plannerResearch = mdq(research).query('code').replace('').toString();
374
- plannerResearch = mdq(plannerResearch)
375
- .query('table')
376
- .replaceEach((table) => {
377
- const rows = table.toJson();
378
- if (rows.length === 0 || !rows[0].Element)
379
- return table.text();
380
- const elementWithType = rows.map((r) => ({
381
- Element: r.Element,
382
- Type: r.Type || '',
383
- }));
384
- return jsonToTable(elementWithType, ['Element', 'Type']);
385
- })
386
- .toString();
388
+ const [plannerResearch, docs] = await Promise.all([this.collectResearch(state), this.collectDocs(state, feature)]);
387
389
  const hasFocusedOverlay = hasFocusedSection(plannerResearch);
388
390
  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." : '';
389
391
  const featureFilter = feature ? `FOCUS FILTER: Only propose scenarios using elements relevant to "${feature}". Ignore all other elements.` : '';
@@ -401,21 +403,22 @@ export class Planner extends PlannerBase {
401
403
  const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
402
404
  if (applicationContext) {
403
405
  conversation.addUserText(applicationContext);
406
+ const words = applicationContext.split(/\s+/).filter(Boolean).length;
407
+ tag('info').log(`Added application spec to planning: ${words} words for ${state.url}`);
404
408
  }
405
- if (docsPromise) {
406
- const docs = await docsPromise;
407
- if (docs) {
408
- conversation.addUserText(dedent `
409
- <docs_context>
410
- Documentation retrieved from the collected corpus by the Scout agent.
411
- Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
409
+ if (docs) {
410
+ conversation.addUserText(dedent `
411
+ <docs_context>
412
+ Documentation retrieved from the collected corpus by the Scout agent.
413
+ Ground scenarios in these documented capabilities where they apply; treat them as supporting context, not a script.
412
414
 
413
- Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
415
+ Aim for roughly ${this.docsWeight}% of the scenarios to exercise behavior documented above; the remainder may explore beyond the documentation.
414
416
 
415
- ${docs}
416
- </docs_context>
417
- `);
418
- }
417
+ ${docs}
418
+ </docs_context>
419
+ `);
420
+ const words = docs.split(/\s+/).filter(Boolean).length;
421
+ tag('info').log(`Added spec to planning (${words} words)`);
419
422
  }
420
423
  let activeRegion = '';
421
424
  if (state.overlay.isOpen)
@@ -579,20 +582,14 @@ export class Planner extends PlannerBase {
579
582
  getTasksMessage() {
580
583
  return dedent `
581
584
  <task>
582
- Provide testing scenarios as structured data with the following requirements:
585
+ Provide testing scenarios with the following requirements:
583
586
  1. Create a short, descriptive plan name that summarizes what will be tested (e.g., "User Authentication Testing", "Product Catalog Navigation", "Form Validation Tests")
584
- 2. Assign priorities based on business importance:
585
- - CRITICAL: Core business functionality that defines the page purpose
586
- - IMPORTANT: Key user flows, primary features, CRUD operations
587
- - HIGH: Secondary features, edge cases for critical flows
588
- - NORMAL: Supporting actions, settings, configuration
589
- - LOW: Cosmetic checks, boundary testing, minor interactions
590
- 3. Propose tests following research section order: ${this.sectionOrder.join(', ')}
587
+ 2. Propose tests following research section order: ${this.sectionOrder.join(', ')}
591
588
  Cover sections in this order — first propose tests for elements from earlier sections, then later ones.
592
589
  Extended Research sections come after the main sections.
593
- 4. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
594
- 5. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
595
- 6. For each task, provide BOTH steps and expected outcomes as separate arrays:
590
+ 3. Focus on interactive elements of the page. Skip navigation links that lead away from current page.
591
+ 4. Focus on tests you are 100% sure relevant to this page and can be achived from UI.
592
+ 5. For each task, provide BOTH steps and expected outcomes as separate lists:
596
593
 
597
594
  STEPS - actionable commands:
598
595
  - Each step should be a specific action to perform
@@ -623,8 +620,63 @@ export class Planner extends PlannerBase {
623
620
  - Do not wrap text in ** or * quotes, ( or ) brackets.
624
621
  - Avoid using emojis or special characters.
625
622
  7. Only tests that can be tested from web UI should be proposed.
626
- 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.
623
+ 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.
627
624
  </task>
625
+
626
+ <output_format>
627
+ Reply with the plan as markdown in exactly this format and nothing else:
628
+
629
+ <!-- suite -->
630
+ # {short plan name summarizing what is tested}
631
+
632
+ <!-- test -->
633
+ # {one sentence describing the behavior to test}
634
+
635
+ ## Steps
636
+ * {first action}
637
+ * {next action}
638
+ * {one bullet per action, as many as the workflow needs}
639
+
640
+ ## Expected
641
+ * {first verifiable outcome}
642
+ * {one bullet per outcome, as many as the scenario verifies}
643
+
644
+ Repeat the block from <!-- test to ## Expected for every scenario.
645
+ Every scenario needs its own <!-- test --> comment, its steps and its expected outcomes.
646
+ Steps list every action of the workflow in order, one action per bullet, from the starting page to the final action.
647
+ Expected lists every outcome to verify, one outcome per bullet.
648
+
649
+ When there is nothing to test, write no scenarios; after the plan name add one comment instead:
650
+ <!-- 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
651
+ <!-- blocked: {one sentence why the page has nothing to test} --> otherwise
652
+ </output_format>
628
653
  `;
629
654
  }
655
+ async collectResearch(state) {
656
+ const currentState = this.stateManager.getCurrentState();
657
+ const research = await this.researcher.research(currentState || state, { deep: true });
658
+ let plannerResearch = mdq(research).query('code').replace('').toString();
659
+ plannerResearch = mdq(plannerResearch)
660
+ .query('table')
661
+ .replaceEach((table) => {
662
+ const rows = table.toJson();
663
+ if (rows.length === 0 || !rows[0].Element)
664
+ return table.text();
665
+ const elementWithType = rows.map((r) => ({
666
+ Element: r.Element,
667
+ Type: r.Type || '',
668
+ }));
669
+ return jsonToTable(elementWithType, ['Element', 'Type']);
670
+ })
671
+ .toString();
672
+ return plannerResearch;
673
+ }
674
+ async collectDocs(state, feature) {
675
+ if (!this.scout || this.docsWeight <= 0)
676
+ return '';
677
+ const query = ScoutQuery.fromState(state);
678
+ query.feature = feature;
679
+ query.exclude = this.knowledgeTracker.applicationSpecUrls(state);
680
+ return this.scout.collectDocs(query);
681
+ }
630
682
  }
@@ -23,7 +23,7 @@ export declare class Provider {
23
23
  stop(): Promise<void>;
24
24
  getModelForAgent(agentName?: string): any;
25
25
  setJudge(judge: Judge | null): void;
26
- oracle(statement: string, state: unknown, options?: any): Promise<boolean>;
26
+ oracle(statement: string, state: unknown, options?: any): Promise<boolean | null>;
27
27
  getAgenticModel(agentName?: string): any;
28
28
  getVisionModel(): any;
29
29
  getConfiguredModels(): Record<string, string>;
@@ -138,13 +138,24 @@ export class Provider {
138
138
  this.judge = judge;
139
139
  }
140
140
  async oracle(statement, state, options = {}) {
141
- const judged = await this.judge?.assert(statement, state);
141
+ const { screenshot, ...generateOptions } = options;
142
+ let judgeState = state;
143
+ if (screenshot)
144
+ judgeState = { ...state, screenshot: `data:image/png;base64,${screenshot.toString('base64')}` };
145
+ const judged = await this.judge?.assert(statement, judgeState);
142
146
  if (typeof judged === 'boolean')
143
147
  return judged;
144
148
  const schema = z.object({ holds: z.boolean().describe('whether the statement is true') });
145
- const content = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
146
- const response = await this.generateObject([{ role: 'user', content }], schema, this.getAgenticModel(options.agentName), options).catch(() => null);
147
- return response?.object?.holds === true;
149
+ let content = `${statement}\n\n${JSON.stringify(state, null, 2)}`;
150
+ if (screenshot)
151
+ content = [
152
+ { type: 'text', text: content },
153
+ { type: 'file', mediaType: 'image/png', data: screenshot },
154
+ ];
155
+ const response = await this.generateObject([{ role: 'user', content }], schema, this.getAgenticModel(generateOptions.agentName), generateOptions).catch(() => null);
156
+ if (!response)
157
+ return null;
158
+ return response.object?.holds === true;
148
159
  }
149
160
  getAgenticModel(agentName) {
150
161
  if (agentName) {
@@ -471,7 +482,7 @@ export class Provider {
471
482
  // Log tool usage summary
472
483
  if (response.toolCalls && response.toolCalls.length > 0) {
473
484
  responseLog(response.toolCalls);
474
- response.toolCalls.forEach((call, index) => {
485
+ response.toolCalls.forEach((call) => {
475
486
  tag('debug').log(`${call.toolName} > ${Object.values(call?.input || []).join(', ')}`);
476
487
  });
477
488
  }
@@ -267,7 +267,7 @@ export class Rerunner extends TaskAgent {
267
267
  async healStep(step, error) {
268
268
  const failedCode = step.toCode?.() || '';
269
269
  console.log(chalk.yellow(` ${figureSet.arrowRight} Healing: ${failedCode}`));
270
- return async ({ I }) => {
270
+ return async () => {
271
271
  const bashTool = await createBashTool({
272
272
  destination: this.traceDir,
273
273
  onBeforeBashCall: ({ command }) => {
@@ -12,6 +12,7 @@ const FINGERPRINT_WORKER_TIMEOUT_MS = 10_000;
12
12
  const SIMILARITY_THRESHOLD = 90;
13
13
  const memoryCache = new TTLCache(CACHE_TTL_MS);
14
14
  let fingerprintWorker = null;
15
+ let fingerprintRequestId = 0;
15
16
  export function researchPath(hash) {
16
17
  return outputPath('research', `${hash}.md`);
17
18
  }
@@ -84,13 +85,13 @@ function findSimilarMatch(combinedHtml, url) {
84
85
  if (!existsSync(statesDir))
85
86
  return Promise.resolve(null);
86
87
  const worker = getFingerprintWorker();
88
+ const id = ++fingerprintRequestId;
87
89
  return new Promise((resolve) => {
88
- const timeout = setTimeout(() => {
89
- debugLog('Fingerprint worker timed out');
90
- resolve(null);
91
- }, FINGERPRINT_WORKER_TIMEOUT_MS);
92
- worker.on('message', (data) => {
90
+ const onMessage = (data) => {
91
+ if (data.id !== id)
92
+ return;
93
93
  clearTimeout(timeout);
94
+ worker.off('message', onMessage);
94
95
  const { matchHash, similarity } = data;
95
96
  if (!matchHash) {
96
97
  resolve(null);
@@ -98,8 +99,15 @@ function findSimilarMatch(combinedHtml, url) {
98
99
  }
99
100
  debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
100
101
  resolve({ hash: matchHash, similarity, url: data.url });
101
- });
102
+ };
103
+ const timeout = setTimeout(() => {
104
+ worker.off('message', onMessage);
105
+ debugLog('Fingerprint worker timed out');
106
+ resolve(null);
107
+ }, FINGERPRINT_WORKER_TIMEOUT_MS);
108
+ worker.on('message', onMessage);
102
109
  worker.postMessage({
110
+ id,
103
111
  html: combinedHtml,
104
112
  statesDir,
105
113
  maxAgeMs: FINGERPRINT_MAX_AGE_MS,
@@ -14,6 +14,7 @@ export declare function WithDeepAnalysis<T extends Constructor>(Base: T): {
14
14
  stateManager: StateManager;
15
15
  config: ExplorbotConfig;
16
16
  actionResult: ActionResult | undefined;
17
+ researchedOverlays: Set<string>;
17
18
  performDeepAnalysis(state: WebPageState, result: ResearchResult): Promise<void>;
18
19
  researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null>;
19
20
  _loadPreviousExtendedSections(hash: string): PreviousSection[];
@@ -26,7 +27,7 @@ export declare function WithDeepAnalysis<T extends Constructor>(Base: T): {
26
27
  code: string;
27
28
  url: string;
28
29
  }>): void;
29
- _discoverExpandables(researchText: string, missing?: PreviousSection[], verifiedCodes?: string[]): Promise<ExpandableElement[]>;
30
+ _discoverExpandables(researchText: string, missing?: PreviousSection[]): Promise<ExpandableElement[]>;
30
31
  _buildClickCommands(el: ExpandableElement): string[];
31
32
  _selectExpandables(expandables: ExpandableElement[], url: string, maxClicks: number): Promise<ExpandableElement[]>;
32
33
  _clickExpandableElements(elements: Array<{
@@ -14,6 +14,7 @@ const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
14
14
  const MAX_HTML_DIFF_CHARS = 20_000;
15
15
  export function WithDeepAnalysis(Base) {
16
16
  return class extends Base {
17
+ researchedOverlays = new Set();
17
18
  async performDeepAnalysis(state, result) {
18
19
  tag('info').log('Starting deep analysis of expandable elements');
19
20
  await this.navigateTo(state.fullUrl || state.url);
@@ -36,7 +37,7 @@ export function WithDeepAnalysis(Base) {
36
37
  return;
37
38
  }
38
39
  }
39
- let expandables = await this._discoverExpandables(result.text, missing, verifiedCodes);
40
+ let expandables = await this._discoverExpandables(result.text, missing);
40
41
  if (expandables.length > maxClicks) {
41
42
  expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
42
43
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
@@ -68,6 +69,10 @@ export function WithDeepAnalysis(Base) {
68
69
  const region = current.overlay;
69
70
  if (!region.isOpen || !region.name)
70
71
  return null;
72
+ const overlayKey = `${pageStateHash}:${region.name}`;
73
+ if (this.researchedOverlays.has(overlayKey))
74
+ return null;
75
+ this.researchedOverlays.add(overlayKey);
71
76
  const cached = getCachedResearch(pageStateHash);
72
77
  if (!cached)
73
78
  return null;
@@ -162,7 +167,7 @@ export function WithDeepAnalysis(Base) {
162
167
  result.text += `\n\n## Navigation Links\n\n${links}`;
163
168
  }
164
169
  }
165
- async _discoverExpandables(researchText, missing = [], verifiedCodes = []) {
170
+ async _discoverExpandables(researchText, missing = []) {
166
171
  const allElements = new Map();
167
172
  for (const section of parseResearchSections(researchText)) {
168
173
  for (const el of section.elements) {
@@ -5,14 +5,14 @@ import { computeHtmlFingerprint } from "../../utils/html-diff.js";
5
5
  import { isSamePageFamily } from "../../utils/url-matcher.js";
6
6
  import { diceSimilarity } from "../../utils/similarity.js";
7
7
  parentPort.on('message', (data) => {
8
- const { html, statesDir, maxAgeMs, threshold, url } = data;
8
+ const { id, html, statesDir, maxAgeMs, threshold, url } = data;
9
9
  if (!existsSync(statesDir)) {
10
- parentPort.postMessage({ matchHash: null, similarity: 0 });
10
+ parentPort.postMessage({ id, matchHash: null, similarity: 0 });
11
11
  return;
12
12
  }
13
13
  const currentFingerprint = new Set(computeHtmlFingerprint(html));
14
14
  if (currentFingerprint.size === 0) {
15
- parentPort.postMessage({ matchHash: null, similarity: 0 });
15
+ parentPort.postMessage({ id, matchHash: null, similarity: 0 });
16
16
  return;
17
17
  }
18
18
  const now = Date.now();
@@ -37,7 +37,7 @@ parentPort.on('message', (data) => {
37
37
  }
38
38
  }
39
39
  const matched = bestSimilarity >= threshold;
40
- parentPort.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
40
+ parentPort.postMessage({ id, matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
41
41
  });
42
42
  function readFingerprint(filePath) {
43
43
  const content = readFileSync(filePath, 'utf8');