explorbot 0.5.10 → 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 (151) hide show
  1. package/bin/explorbot-cli.ts +494 -482
  2. package/boat/api-tester/src/ai/curler.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +4 -4
  4. package/boat/doc-collector/src/screenshots.ts +1 -1
  5. package/dist/bin/explorbot-cli.js +455 -444
  6. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  7. package/dist/boat/api-tester/src/cli.js +4 -4
  8. package/dist/models.json +1 -1
  9. package/dist/package.json +2 -1
  10. package/dist/rules/navigator/output.md +2 -8
  11. package/dist/rules/navigator/verification-actions.md +6 -2
  12. package/dist/rules/planner/ignored-pages.md +1 -0
  13. package/dist/src/action-result.js +1 -1
  14. package/dist/src/action.d.ts +0 -1
  15. package/dist/src/action.js +0 -4
  16. package/dist/src/ai/captain/idle-mode.js +42 -26
  17. package/dist/src/ai/captain/mixin.d.ts +2 -0
  18. package/dist/src/ai/captain/web-mode.js +11 -0
  19. package/dist/src/ai/captain.d.ts +6 -4
  20. package/dist/src/ai/captain.js +25 -1
  21. package/dist/src/ai/driller.js +1 -1
  22. package/dist/src/ai/experience-compactor.js +2 -2
  23. package/dist/src/ai/judge.d.ts +7 -0
  24. package/dist/src/ai/judge.js +8 -1
  25. package/dist/src/ai/navigator.d.ts +1 -0
  26. package/dist/src/ai/navigator.js +11 -7
  27. package/dist/src/ai/planner/subpages.js +1 -1
  28. package/dist/src/ai/planner.d.ts +12 -5
  29. package/dist/src/ai/planner.js +138 -86
  30. package/dist/src/ai/provider.d.ts +1 -1
  31. package/dist/src/ai/provider.js +16 -5
  32. package/dist/src/ai/rerunner.js +1 -1
  33. package/dist/src/ai/researcher/cache.js +14 -6
  34. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  35. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  36. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  37. package/dist/src/ai/researcher.js +44 -24
  38. package/dist/src/ai/rules.js +6 -7
  39. package/dist/src/ai/scout.d.ts +12 -2
  40. package/dist/src/ai/scout.js +103 -8
  41. package/dist/src/ai/tester.d.ts +9 -5
  42. package/dist/src/ai/tester.js +22 -42
  43. package/dist/src/ai/tools.js +10 -8
  44. package/dist/src/application-spec.d.ts +2 -0
  45. package/dist/src/application-spec.js +17 -12
  46. package/dist/src/commands/context-experience-command.js +0 -1
  47. package/dist/src/commands/context-knowledge-command.js +0 -1
  48. package/dist/src/commands/explore-command.d.ts +3 -2
  49. package/dist/src/commands/explore-command.js +35 -23
  50. package/dist/src/commands/help-json-command.js +3 -1
  51. package/dist/src/commands/init-command.js +4 -4
  52. package/dist/src/commands/learn-command.js +0 -2
  53. package/dist/src/commands/plan-command.d.ts +3 -0
  54. package/dist/src/commands/plan-command.js +43 -17
  55. package/dist/src/commands/plan-save-command.js +6 -17
  56. package/dist/src/commands/plans-command.js +1 -1
  57. package/dist/src/commands/test-command.js +12 -2
  58. package/dist/src/components/App.d.ts +1 -2
  59. package/dist/src/components/App.js +1 -3
  60. package/dist/src/components/InputPane.js +0 -5
  61. package/dist/src/config.d.ts +3 -2
  62. package/dist/src/config.js +2 -12
  63. package/dist/src/explorbot.d.ts +3 -2
  64. package/dist/src/explorbot.js +32 -10
  65. package/dist/src/explorer.js +6 -7
  66. package/dist/src/knowledge-tracker.d.ts +2 -2
  67. package/dist/src/knowledge-tracker.js +18 -6
  68. package/dist/src/playwright-recorder.d.ts +0 -1
  69. package/dist/src/playwright-recorder.js +0 -2
  70. package/dist/src/reporter.d.ts +1 -1
  71. package/dist/src/reporter.js +3 -3
  72. package/dist/src/test-plan.js +1 -1
  73. package/dist/src/utils/error-page.d.ts +17 -0
  74. package/dist/src/utils/error-page.js +53 -2
  75. package/dist/src/utils/html-diff.d.ts +1 -2
  76. package/dist/src/utils/html-diff.js +4 -44
  77. package/dist/src/utils/logger.js +27 -10
  78. package/dist/src/utils/markdown-terminal.js +1 -2
  79. package/dist/src/utils/next-steps.js +20 -16
  80. package/dist/src/utils/page-readiness.d.ts +2 -1
  81. package/dist/src/utils/page-readiness.js +24 -5
  82. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  83. package/dist/src/utils/test-plan-markdown.js +35 -4
  84. package/dist/src/utils/url-matcher.d.ts +1 -0
  85. package/dist/src/utils/url-matcher.js +5 -0
  86. package/docs/basics/providers.md +1 -1
  87. package/docs/contributing/regression-tests.md +19 -4
  88. package/docs/reference/commands.md +6 -2
  89. package/docs/reference/configuration.md +1 -1
  90. package/docs/web-testing/agents.md +8 -0
  91. package/docs/web-testing/customization.md +2 -0
  92. package/docs/web-testing/researcher.md +1 -1
  93. package/models.json +1 -1
  94. package/package.json +2 -1
  95. package/rules/navigator/output.md +2 -8
  96. package/rules/navigator/verification-actions.md +6 -2
  97. package/rules/planner/ignored-pages.md +1 -0
  98. package/src/action-result.ts +1 -2
  99. package/src/action.ts +0 -5
  100. package/src/ai/captain/idle-mode.ts +38 -26
  101. package/src/ai/captain/mixin.ts +2 -1
  102. package/src/ai/captain/web-mode.ts +12 -0
  103. package/src/ai/captain.ts +25 -7
  104. package/src/ai/driller.ts +1 -2
  105. package/src/ai/experience-compactor.ts +2 -2
  106. package/src/ai/judge.ts +10 -1
  107. package/src/ai/navigator.ts +12 -7
  108. package/src/ai/planner/subpages.ts +1 -2
  109. package/src/ai/planner.ts +140 -92
  110. package/src/ai/provider.ts +15 -6
  111. package/src/ai/rerunner.ts +1 -2
  112. package/src/ai/researcher/cache.ts +16 -7
  113. package/src/ai/researcher/deep-analysis.ts +7 -2
  114. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  115. package/src/ai/researcher.ts +44 -28
  116. package/src/ai/rules.ts +6 -7
  117. package/src/ai/scout.ts +98 -10
  118. package/src/ai/tester.ts +29 -47
  119. package/src/ai/tools.ts +11 -9
  120. package/src/application-spec.ts +19 -13
  121. package/src/commands/clean-command.ts +1 -1
  122. package/src/commands/context-command.ts +0 -1
  123. package/src/commands/context-experience-command.ts +0 -1
  124. package/src/commands/context-knowledge-command.ts +0 -1
  125. package/src/commands/explore-command.ts +36 -23
  126. package/src/commands/help-json-command.ts +8 -6
  127. package/src/commands/init-command.ts +5 -3
  128. package/src/commands/learn-command.ts +0 -2
  129. package/src/commands/plan-command.ts +44 -17
  130. package/src/commands/plan-save-command.ts +6 -17
  131. package/src/commands/plans-command.ts +1 -1
  132. package/src/commands/test-command.ts +11 -2
  133. package/src/components/App.tsx +2 -6
  134. package/src/components/InputPane.tsx +0 -5
  135. package/src/config.ts +4 -16
  136. package/src/experience-tracker.ts +1 -1
  137. package/src/explorbot.ts +31 -11
  138. package/src/explorer.ts +6 -7
  139. package/src/knowledge-tracker.ts +18 -7
  140. package/src/playwright-recorder.ts +0 -2
  141. package/src/reporter.ts +3 -4
  142. package/src/test-plan.ts +1 -2
  143. package/src/utils/error-page.ts +64 -2
  144. package/src/utils/html-diff.ts +4 -56
  145. package/src/utils/html.ts +0 -1
  146. package/src/utils/logger.ts +30 -12
  147. package/src/utils/markdown-terminal.ts +1 -2
  148. package/src/utils/next-steps.ts +19 -17
  149. package/src/utils/page-readiness.ts +33 -7
  150. package/src/utils/test-plan-markdown.ts +36 -4
  151. package/src/utils/url-matcher.ts +6 -0
@@ -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 };
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
3
  import { setActivity } from '../activity.ts';
4
- import { ConfigParser, type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
4
+ import { type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
5
5
  import { executionController } from '../execution-controller.ts';
6
6
  import type { ExperienceTracker } from '../experience-tracker.ts';
7
7
  import type Explorer from '../explorer.ts';
@@ -10,13 +10,15 @@ import type { StateManager } from '../state-manager.js';
10
10
  import { WebPageState } from '../state-manager.js';
11
11
  import { Stats } from '../stats.ts';
12
12
  import { diffAriaSnapshots } from '../utils/aria.ts';
13
- import { ErrorPageError, detectPageCondition } from '../utils/error-page.ts';
13
+ import { ErrorPageError, type PageCondition, detectPageCondition } from '../utils/error-page.ts';
14
14
  import { isBodyEmpty } from '../utils/html.ts';
15
15
  import { createDebug, tag } from '../utils/logger.js';
16
16
  import { mdq } from '../utils/markdown-query.ts';
17
+ import { waitForDomChange, waitForPageReadiness } from '../utils/page-readiness.ts';
17
18
  import { RulesLoader } from '../utils/rules-loader.ts';
18
19
  import { annotatePageElements } from '../utils/web-annotate.ts';
19
20
  import type { Agent, AgentDeps } from './agent.js';
21
+ import { UNDECIDED, pageEvidence } from './judge.ts';
20
22
  import type { Navigator } from './navigator.ts';
21
23
  import { ContextLengthError, type Provider } from './provider.js';
22
24
  import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from './researcher/cache.ts';
@@ -34,6 +36,7 @@ import { TaskAgent } from './task-agent.ts';
34
36
  export type { Locator } from './researcher/locators.ts';
35
37
 
36
38
  const debugLog = createDebug('explorbot:researcher');
39
+ const HIDDEN_CONTENT_STATEMENT = 'The page has collapsed or hidden content behind controls that open or expand it.';
37
40
 
38
41
  export const POSSIBLE_SECTIONS = {
39
42
  overlay: 'dialog, modal, drawer, popup, or active form overlay',
@@ -136,7 +139,8 @@ export class Researcher extends ResearcherBase implements Agent {
136
139
  if (light) tag('info').log('Light research: single pass, locator verification skipped');
137
140
  setActivity(`${this.emoji} Researching...`, 'action');
138
141
 
139
- await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
142
+ const retrying = opts._retriesLeft !== undefined;
143
+ if (!retrying) await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
140
144
  await this.hooksRunner.runBeforeHook('researcher', state.url);
141
145
 
142
146
  const { elements: annotatedElements } = await this.explorer.withPage(annotatePageElements);
@@ -148,7 +152,7 @@ export class Researcher extends ResearcherBase implements Agent {
148
152
  tag('warning').log(`Detected error page at ${state.url}`);
149
153
  throw new ErrorPageError(state.url, this.actionResult!.title, this.actionResult!.httpStatus);
150
154
  }
151
- if (condition === 'loading') {
155
+ if (condition === 'loading' && !retrying) {
152
156
  const settled = await this.waitUntilSettled(screenshot);
153
157
  if (!settled) {
154
158
  tag('warning').log(`Page at ${state.url} did not finish loading within timeout, continuing with best-effort research`);
@@ -290,7 +294,7 @@ export class Researcher extends ResearcherBase implements Agent {
290
294
  if (fallback) markSectionAsFocused(result, fallback);
291
295
  }
292
296
 
293
- if (!interrupted() && deep) {
297
+ if (!interrupted() && deep && (await this.hasHiddenContent())) {
294
298
  try {
295
299
  await this.performDeepAnalysis(researchState, result);
296
300
  } catch (err) {
@@ -365,37 +369,49 @@ export class Researcher extends ResearcherBase implements Agent {
365
369
 
366
370
  private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
367
371
  const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
368
- if (errorPageTimeout <= 0) return false;
372
+ const deadline = Date.now() + errorPageTimeout * 1000;
369
373
 
370
- const includeScreenshot = screenshot && this.provider.hasVision();
371
-
372
- try {
373
- await this.explorer.withPage((page) => page.waitForLoadState('networkidle', { timeout: errorPageTimeout * 1000 }));
374
- } catch {}
375
-
376
- await this.explorer.withPage(annotatePageElements);
377
- this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
378
-
379
- let condition = detectPageCondition(this.actionResult!);
380
- if (condition === 'error') {
381
- throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
382
- }
383
- if (condition === 'ok') return true;
384
-
385
- for (let i = 0; i < 3; i++) {
386
- await new Promise((r) => setTimeout(r, 1000));
374
+ for (let remaining = errorPageTimeout * 1000; remaining > 0; remaining = deadline - Date.now()) {
375
+ const rendered = await this.explorer.withPage((page) => waitForPageReadiness(page, { timeout: remaining, spinnerSelectors: this.config.playwright.spinnerSelectors }));
387
376
  await this.explorer.withPage(annotatePageElements);
388
- this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
389
- condition = detectPageCondition(this.actionResult!);
390
- if (condition === 'error') {
391
- throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
392
- }
377
+ this.actionResult = await this.explorer.capture({ screenshot: screenshot && this.provider.hasVision() });
378
+ const condition = await this.pageCondition(rendered);
379
+ if (condition === 'error') throw new ErrorPageError(this.actionResult!.url, this.actionResult!.title, this.actionResult!.httpStatus);
393
380
  if (condition === 'ok') return true;
381
+
382
+ tag('step').log('Page is still loading, waiting for content...');
383
+ if (!(await this.explorer.withPage((page) => waitForDomChange(page, deadline - Date.now())))) return false;
394
384
  }
395
385
 
396
386
  return false;
397
387
  }
398
388
 
389
+ private async pageCondition(rendered: boolean): Promise<PageCondition> {
390
+ const condition = detectPageCondition(this.actionResult!);
391
+ if (condition !== 'loading') return condition;
392
+ if (!this.judge && rendered) return 'ok';
393
+ if (!this.judge) return condition;
394
+
395
+ const conditions: Record<string, PageCondition> = {
396
+ 'still loading: blank, splash screen, skeleton or loading placeholder': 'loading',
397
+ 'error page: shows an error instead of the requested content': 'error',
398
+ 'loaded: the main content is rendered': 'ok',
399
+ };
400
+ const decision = await this.judge.decide('The page was blank or barely rendered when it opened. What state is it in now?', [...Object.keys(conditions), UNDECIDED], pageEvidence(this.actionResult!));
401
+ return conditions[decision.value ?? ''] || 'ok';
402
+ }
403
+
404
+ private async hasHiddenContent(): Promise<boolean> {
405
+ const captured = await this.explorer.capture({ screenshot: true });
406
+ const hidden = await this.provider.oracle(HIDDEN_CONTENT_STATEMENT, pageEvidence(captured), { agentName: 'researcher', screenshot: captured.screenshot, telemetry: { functionId: 'researcher.hasHiddenContent' } });
407
+ if (hidden === null) {
408
+ tag('warning').log('Could not check the page for hidden content, running deep analysis');
409
+ return true;
410
+ }
411
+ if (!hidden) tag('step').log('Nothing to expand on this page, skipping deep analysis');
412
+ return hidden;
413
+ }
414
+
399
415
  private getConfiguredSections(): Record<string, string> {
400
416
  const configSections = this.settings.sections;
401
417
  if (!configSections?.length) return POSSIBLE_SECTIONS;
package/src/ai/rules.ts CHANGED
@@ -45,7 +45,7 @@ const locatorStrategyRule = dedent`
45
45
  3. Use ARIA snapshot to identify element state (focused, visible, selected, expanded, etc.)
46
46
  4. Match semantic proximity - elements near fields you just interacted with
47
47
 
48
- Once the correct element is identified pick the best unique locator following priority: ARIA → CSS → XPath
48
+ Once the correct element is identified pick the best unique locator following priority: ARIA → text → CSS → XPath
49
49
  </disambiguation>
50
50
 
51
51
  ARIA locators must specify role. Specify locator type as JSON string with role and text keys.
@@ -101,7 +101,7 @@ const locatorStrategyRule = dedent`
101
101
  <xpath_rules>
102
102
  XPath locators must start with //.
103
103
  XPath may use positional indices [1], [2] and contains(., "text") as a last-resort disambiguator when attribute/text strategies are exhausted.
104
- XPath should rely less on class names — prefer element hierarchy, position, and text content.
104
+ XPath should rely less on class names — prefer element hierarchy, attributes, and text content.
105
105
  XPath and CSS MUST provide different strategies for finding the same element.
106
106
  </xpath_rules>
107
107
 
@@ -118,7 +118,7 @@ const locatorStrategyRule = dedent`
118
118
 
119
119
  <bad locator example>
120
120
  'a.filter-tab:nth-of-type(1)' // WRONG: positional in CSS, use :has-text("Active") instead
121
- '//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[contains(@class,"filter-tab")][1]
121
+ '//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[normalize-space()="Active"]
122
122
  '//table//tbody/tr[1]//button[contains(@onclick,'fn()')]' // onclick is not semantic attribute
123
123
  '//html/body/vue-button-123' // vue-framework specific locator
124
124
  'link "New Item"' // WRONG: malformed string, use {"role":"link","text":"New Item"}
@@ -225,7 +225,7 @@ export const focusedElementRule = dedent`
225
225
  When a text input element is focused (textbox, combobox, contenteditable):
226
226
 
227
227
  To CLEAR the field before typing:
228
- - I.pressKey(['Meta', 'a']); // Select all (use 'Control' on Windows/Linux)
228
+ - I.pressKey(['CommandOrControl', 'a']); // Select all
229
229
  - I.pressKey('Backspace'); // Delete selection
230
230
 
231
231
  To TYPE into the focused element:
@@ -420,8 +420,7 @@ export const actionRule = dedent`
420
420
  I.pressKey('Escape'); // Press Escape key
421
421
  I.pressKey('Tab'); // Press Tab key
422
422
  I.pressKey('Backspace'); // Press Backspace key
423
- I.pressKey(['Control', 'a']); // Select all (Ctrl+A)
424
- I.pressKey(['Meta', 'a']); // Select all on Mac (Cmd+A)
423
+ I.pressKey(['CommandOrControl', 'a']); // Select all
425
424
  I.pressKey(['Shift', 'Tab']); // Shift+Tab to go back
426
425
  I.pressKey('ArrowDown'); // Navigate dropdown options
427
426
  </example>
@@ -518,7 +517,7 @@ export const actionRule = dedent`
518
517
  I.seeClipboardEquals('ABC-123');
519
518
  </example>
520
519
 
521
- [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
520
+ [Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
522
521
  [INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
523
522
  [DO NOT USE WAIT FUNCTIONS]
524
523
 
package/src/ai/scout.ts CHANGED
@@ -1,6 +1,12 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
1
4
  import dedent from 'dedent';
5
+ import matter from 'gray-matter';
6
+ import { outputPath } from '../config.ts';
2
7
  import { tag } from '../utils/logger.ts';
3
8
  import { loop } from '../utils/loop.ts';
9
+ import { sanitizeFilename } from '../utils/strings.ts';
4
10
  import type { Agent } from './agent.ts';
5
11
  import type { Provider } from './provider.ts';
6
12
  import { type ScoutCorpus, createScoutTools, excludeCorpusUrls } from './scout/tools.ts';
@@ -9,6 +15,7 @@ const MAX_ITERATIONS = 3;
9
15
  const MAX_TOOL_ROUNDTRIPS = 5;
10
16
  const CACHE_LIMIT = 40;
11
17
  const URL_LISTING_LIMIT = 40;
18
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
12
19
 
13
20
  export class Scout implements Agent {
14
21
  emoji = '🔎';
@@ -26,11 +33,18 @@ export class Scout implements Agent {
26
33
  async collectDocs(query: ScoutQuery): Promise<string> {
27
34
  if (!this.isAvailable()) return '';
28
35
 
29
- const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
36
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
30
37
  const cached = this.cache.get(cacheKey);
31
38
  if (cached !== undefined) return cached;
32
39
 
33
- const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls);
40
+ const fileCached = this.loadCachedDigest(query);
41
+ if (fileCached !== null) {
42
+ if (this.cache.size > CACHE_LIMIT) this.cache.clear();
43
+ this.cache.set(cacheKey, fileCached);
44
+ return fileCached;
45
+ }
46
+
47
+ const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls || []);
34
48
  if (corpus.files.length === 0) return '';
35
49
 
36
50
  const result = await this.runSession(corpus, query);
@@ -90,9 +104,13 @@ export class Scout implements Agent {
90
104
 
91
105
  const digest = getResult();
92
106
  if (digest) {
93
- const preview = digest.slice(0, 600);
94
- const ellipsis = digest.length > 600 ? '…' : '';
95
- tag('info').log(`Scout digest:\n${preview}${ellipsis}`);
107
+ const filePath = this.saveDigest(query, digest);
108
+ const words = digest.split(/\s+/).filter(Boolean).length;
109
+ tag('info').log(`Scout found relevant docs: ${words} words → ${filePath}`);
110
+ const lines = digest.split('\n').slice(0, 5).join('\n');
111
+ tag('substep').log(lines);
112
+ } else {
113
+ tag('info').log(`Scout found no relevant docs for ${query.feature || query.url || 'the current page'}`);
96
114
  }
97
115
  return digest;
98
116
  }
@@ -129,7 +147,7 @@ export class Scout implements Agent {
129
147
  ${pagesListing}
130
148
 
131
149
  These pages are already provided to the planner in full — do not re-report them:
132
- ${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
150
+ ${(query.excludeUrls || []).map((url) => `- ${url}`).join('\n') || '- none'}
133
151
 
134
152
  AVAILABLE TOOLS:
135
153
  ${toolNames.join(', ')}.
@@ -144,12 +162,32 @@ export class Scout implements Agent {
144
162
  2. Read the files whose hits look most relevant
145
163
  3. Report the digest as your final message — no tool call is needed to finish
146
164
 
147
- RULES:
165
+ <rules>
148
166
  - Report only what the documentation states. Never fill gaps with assumptions about the application
149
167
  - Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
150
168
  - Name the page URL each item belongs to, so scenarios anchor to real routes
151
- - Explore briefly: a few scans and reads are enough, then report
152
169
  - A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
170
+ - For vague terms such as valid, supported, recent, all, or available, seek exact definitions and provide example of valid/invalid values.
171
+ - Follow relevant references for missing rules, shared policies, exceptions, and conflicting claims.
172
+ </rules>
173
+
174
+ Look for facts we need to collect for testing the related feature:
175
+
176
+ <facts>
177
+ - **Actors/access:** Find exact roles, permissions, ownership, scope, and denial behavior. Distinguish hidden, disabled, forbidden, and redirected.
178
+ - **Prerequisites:** Find required data, settings, integrations, plans, flags, and dependencies.
179
+ - **Actions/navigation:** Find entry points, available operations, destinations, confirmations, and cancellation behavior.
180
+ - **Inputs/validation:** Find accepted values, formats, defaults, required fields, exact limits/units, normalization, and error messages. Distinguish omitted, empty, whitespace, and unchanged.
181
+ - **Duplicates:** Find identity rules, uniqueness scope, and whether matches are rejected, reused, merged, or updated.
182
+ - **Conditions/states:** Find guards, exceptions, state transitions, prohibited actions, and intentional no-ops.
183
+ - **Outcomes:** Find visible feedback, persistent changes, side effects, and subsequent places where results appear.
184
+ - **Failures:** Find retained changes, rollback, partial success, recovery, and retry behavior. Never assume an error means nothing changed.
185
+ - **Async/time:** Find start versus completion, progress indicators, refresh behavior, delays, expiry, schedules, and retry limits.
186
+ - **Lists/bulk actions:** Find search/filter semantics, sorting, pagination, count definitions, selection scope, and skipped-item behavior.
187
+ - **Relationships/deletion:** Find inheritance, cascades, copy versus reference, unlink versus delete, restoration, and irreversible effects.
188
+ - **Files/integrations:** Find supported formats, mappings, processing limits, sync direction, overwrite precedence, and unavailable-service behavior.
189
+ - **Variants:** Find differences by role, plan, configuration, deployment, browser, or channel. Keep browser and API behavior separate.
190
+ </facts>
153
191
  `;
154
192
 
155
193
  const customPrompt = this.provider.getSystemPromptForAgent('scout');
@@ -166,15 +204,65 @@ export class Scout implements Agent {
166
204
  Report the documented capabilities, states and transitions a test planner could turn into scenarios.
167
205
  `;
168
206
  }
207
+
208
+ private saveDigest(query: ScoutQuery, digest: string): string {
209
+ const filePath = this.digestPath(query);
210
+ const dir = outputPath('scout');
211
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
212
+ const frontmatter: Record<string, any> = {};
213
+ if (query.url) frontmatter.url = query.url;
214
+ if (query.feature) frontmatter.feature = query.feature;
215
+ if (query.excludeUrls?.length) frontmatter.excludeUrls = query.excludeUrls;
216
+ frontmatter.cachedAt = new Date().toISOString();
217
+ const content = matter.stringify(digest, frontmatter);
218
+ writeFileSync(filePath, content, 'utf8');
219
+ tag('data').log('scout', { path: filePath, content: digest });
220
+ return filePath;
221
+ }
222
+
223
+ private digestPath(query: ScoutQuery): string {
224
+ const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
225
+ const hash = createHash('sha1').update(cacheKey).digest('hex').slice(0, 8);
226
+ const raw = query.feature || query.url || 'general';
227
+ const name = sanitizeFilename(raw) || 'general';
228
+ return join(outputPath('scout'), `digest-${name}-${hash}.md`);
229
+ }
230
+
231
+ private loadCachedDigest(query: ScoutQuery): string | null {
232
+ const filePath = this.digestPath(query);
233
+ if (!existsSync(filePath)) return null;
234
+ if (Date.now() - statSync(filePath).mtimeMs > CACHE_TTL_MS) return null;
235
+ const content = matter(readFileSync(filePath, 'utf8')).content;
236
+ const words = content.split(/\s+/).filter(Boolean).length;
237
+ tag('info').log(`Scout found previous spec digest in ${relative(process.cwd(), filePath)}`);
238
+ tag('multiline').log(content);
239
+ return content;
240
+ }
169
241
  }
170
242
 
171
243
  function toPosix(path: string): string {
172
244
  return path.split('\\').join('/');
173
245
  }
174
246
 
175
- export interface ScoutQuery {
247
+ export class ScoutQuery {
176
248
  url?: string;
177
249
  title?: string;
178
250
  feature?: string;
179
- excludeUrls: string[];
251
+ excludeUrls?: string[];
252
+
253
+ constructor(init?: Partial<ScoutQuery>) {
254
+ if (init) Object.assign(this, init);
255
+ }
256
+
257
+ static fromState(state: { url?: string; title?: string }): ScoutQuery {
258
+ return new ScoutQuery({ url: state.url, title: state.title });
259
+ }
260
+
261
+ get exclude(): string[] | undefined {
262
+ return this.excludeUrls;
263
+ }
264
+
265
+ set exclude(value: string[] | undefined) {
266
+ this.excludeUrls = value;
267
+ }
180
268
  }