explorbot 0.2.3 → 0.2.5

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 (174) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +26 -8
  3. package/boat/api-tester/src/cli.ts +17 -0
  4. package/boat/api-tester/src/config.ts +4 -2
  5. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  6. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  7. package/boat/doc-collector/src/cli.ts +14 -1
  8. package/boat/doc-collector/src/config.ts +4 -2
  9. package/boat/prima/bin/prima-cli.ts +0 -0
  10. package/boat/prima/src/activity-line.ts +33 -0
  11. package/boat/prima/src/cli.ts +127 -86
  12. package/boat/prima/src/envelope.ts +102 -52
  13. package/boat/prima/src/prima.ts +567 -128
  14. package/boat/prima/src/pw-parser.ts +11 -1
  15. package/boat/prima/src/pw-registry.ts +4 -5
  16. package/boat/prima/src/session-log.ts +126 -0
  17. package/dist/bin/explorbot-cli.js +26 -8
  18. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  19. package/dist/boat/api-tester/src/cli.js +17 -0
  20. package/dist/boat/api-tester/src/config.js +4 -2
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  22. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  23. package/dist/boat/doc-collector/src/cli.js +14 -1
  24. package/dist/boat/doc-collector/src/config.js +4 -2
  25. package/dist/boat/prima/src/activity-line.js +30 -0
  26. package/dist/boat/prima/src/cli.js +109 -77
  27. package/dist/boat/prima/src/envelope.js +94 -44
  28. package/dist/boat/prima/src/prima.js +533 -119
  29. package/dist/boat/prima/src/pw-parser.js +13 -1
  30. package/dist/boat/prima/src/pw-registry.js +4 -5
  31. package/dist/boat/prima/src/session-log.js +108 -0
  32. package/dist/package.json +3 -2
  33. package/dist/rules/navigator/verification-actions.md +20 -0
  34. package/dist/src/action-result.d.ts +7 -0
  35. package/dist/src/action-result.js +4 -0
  36. package/dist/src/action.d.ts +2 -0
  37. package/dist/src/action.js +41 -2
  38. package/dist/src/ai/captain/web-mode.js +6 -3
  39. package/dist/src/ai/captain.js +2 -0
  40. package/dist/src/ai/navigator.d.ts +34 -0
  41. package/dist/src/ai/navigator.js +237 -181
  42. package/dist/src/ai/pilot.d.ts +7 -0
  43. package/dist/src/ai/pilot.js +90 -2
  44. package/dist/src/ai/provider.d.ts +2 -2
  45. package/dist/src/ai/provider.js +14 -23
  46. package/dist/src/ai/rerunner.js +2 -1
  47. package/dist/src/ai/researcher/cache.d.ts +2 -0
  48. package/dist/src/ai/researcher/cache.js +10 -2
  49. package/dist/src/ai/researcher.js +3 -2
  50. package/dist/src/ai/rules.js +17 -10
  51. package/dist/src/ai/session-analyst.js +2 -0
  52. package/dist/src/ai/task-agent.js +4 -1
  53. package/dist/src/ai/tester.d.ts +6 -3
  54. package/dist/src/ai/tester.js +50 -46
  55. package/dist/src/ai/tools.d.ts +14 -0
  56. package/dist/src/ai/tools.js +117 -37
  57. package/dist/src/commands/config-command.d.ts +51 -0
  58. package/dist/src/commands/config-command.js +117 -0
  59. package/dist/src/commands/index.js +2 -0
  60. package/dist/src/config.d.ts +9 -1
  61. package/dist/src/config.js +53 -4
  62. package/dist/src/execution-controller.d.ts +2 -0
  63. package/dist/src/execution-controller.js +6 -0
  64. package/dist/src/explorbot.d.ts +2 -1
  65. package/dist/src/explorbot.js +7 -2
  66. package/dist/src/explorer.js +2 -3
  67. package/dist/src/playwright-recorder.js +30 -0
  68. package/dist/src/remote.d.ts +55 -0
  69. package/dist/src/remote.js +235 -0
  70. package/dist/src/reporter.d.ts +1 -0
  71. package/dist/src/reporter.js +7 -1
  72. package/dist/src/state-manager.d.ts +2 -1
  73. package/dist/src/state-manager.js +3 -1
  74. package/dist/src/stats.d.ts +1 -0
  75. package/dist/src/stats.js +1 -0
  76. package/dist/src/test-plan.d.ts +3 -0
  77. package/dist/src/test-plan.js +26 -0
  78. package/dist/src/utils/aria.d.ts +2 -8
  79. package/dist/src/utils/aria.js +69 -40
  80. package/dist/src/utils/html.js +1 -0
  81. package/dist/src/utils/logger.d.ts +7 -1
  82. package/dist/src/utils/logger.js +32 -0
  83. package/dist/src/utils/page-readiness.js +18 -1
  84. package/dist/src/utils/url-matcher.js +3 -0
  85. package/dist/src/utils/web-element.d.ts +2 -0
  86. package/dist/src/utils/web-element.js +8 -0
  87. package/dist/src/utils/web-sandbox.d.ts +1 -1
  88. package/dist/src/utils/web-sandbox.js +2 -3
  89. package/docs/api-testing/basics.md +90 -0
  90. package/docs/api-testing/planning.md +57 -0
  91. package/docs/api-testing/running-tests.md +55 -0
  92. package/docs/assets/cloud-report.png +0 -0
  93. package/docs/assets/html-report.png +0 -0
  94. package/docs/assets/langfuse-trace.png +0 -0
  95. package/docs/assets/successful-explore-run.png +0 -0
  96. package/docs/basics/getting-started.md +140 -0
  97. package/docs/basics/prerequisites.md +63 -0
  98. package/docs/basics/providers.md +362 -0
  99. package/docs/basics/running.md +78 -0
  100. package/docs/contributing/ai-integration-tests.md +57 -0
  101. package/docs/contributing/contributing.md +90 -0
  102. package/docs/contributing/demo-videos.md +36 -0
  103. package/docs/contributing/npm-package.md +138 -0
  104. package/docs/contributing/observability.md +227 -0
  105. package/docs/contributing/regression-tests.md +103 -0
  106. package/docs/contributing/testing.md +95 -0
  107. package/docs/doc-collection/basics.md +128 -0
  108. package/docs/doc-collection/crawling.md +67 -0
  109. package/docs/doc-collection/interactive-mode.md +99 -0
  110. package/docs/index.json +87 -0
  111. package/docs/reference/commands.md +997 -0
  112. package/docs/reference/configuration.md +569 -0
  113. package/docs/reference/scripting.md +303 -0
  114. package/docs/reference/websocket.md +50 -0
  115. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  116. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  117. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  118. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  119. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  120. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  121. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  122. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  123. package/docs/web-testing/agents.md +158 -0
  124. package/docs/web-testing/automated-tests.md +134 -0
  125. package/docs/web-testing/basics.md +91 -0
  126. package/docs/web-testing/customization.md +131 -0
  127. package/docs/web-testing/hooks.md +238 -0
  128. package/docs/web-testing/page-interaction.md +84 -0
  129. package/docs/web-testing/planner.md +122 -0
  130. package/docs/web-testing/rerun.md +164 -0
  131. package/docs/web-testing/researcher.md +380 -0
  132. package/docs/workflow/agentic-usage.md +233 -0
  133. package/docs/workflow/application-spec.md +73 -0
  134. package/docs/workflow/ci.md +202 -0
  135. package/docs/workflow/knowledge.md +310 -0
  136. package/docs/workflow/planning-styles.md +67 -0
  137. package/docs/workflow/reporting.md +133 -0
  138. package/docs/workflow/test-plans.md +90 -0
  139. package/package.json +3 -2
  140. package/rules/navigator/verification-actions.md +20 -0
  141. package/src/action-result.ts +11 -0
  142. package/src/action.ts +43 -3
  143. package/src/ai/captain/web-mode.ts +6 -3
  144. package/src/ai/captain.ts +3 -0
  145. package/src/ai/navigator.ts +255 -186
  146. package/src/ai/pilot.ts +104 -2
  147. package/src/ai/provider.ts +14 -24
  148. package/src/ai/rerunner.ts +2 -1
  149. package/src/ai/researcher/cache.ts +12 -2
  150. package/src/ai/researcher.ts +3 -2
  151. package/src/ai/rules.ts +17 -10
  152. package/src/ai/session-analyst.ts +2 -0
  153. package/src/ai/task-agent.ts +3 -1
  154. package/src/ai/tester.ts +52 -45
  155. package/src/ai/tools.ts +136 -37
  156. package/src/commands/config-command.ts +146 -0
  157. package/src/commands/index.ts +2 -0
  158. package/src/config.ts +60 -5
  159. package/src/execution-controller.ts +8 -0
  160. package/src/explorbot.ts +7 -3
  161. package/src/explorer.ts +2 -2
  162. package/src/playwright-recorder.ts +23 -0
  163. package/src/remote.ts +244 -0
  164. package/src/reporter.ts +7 -1
  165. package/src/state-manager.ts +6 -2
  166. package/src/stats.ts +1 -0
  167. package/src/test-plan.ts +29 -0
  168. package/src/utils/aria.ts +65 -45
  169. package/src/utils/html.ts +1 -0
  170. package/src/utils/logger.ts +33 -2
  171. package/src/utils/page-readiness.ts +24 -1
  172. package/src/utils/url-matcher.ts +3 -0
  173. package/src/utils/web-element.ts +9 -0
  174. package/src/utils/web-sandbox.ts +3 -4
@@ -8,11 +8,15 @@ import type { ExperienceTracker } from '../experience-tracker.js';
8
8
  import Explorer from '../explorer.ts';
9
9
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
10
  import { type StateManager, normalizeUrl } from '../state-manager.js';
11
+ import { renderAssertion } from '../playwright-recorder.ts';
12
+ import { isFatalBrowserError } from '../utils/browser-errors.ts';
13
+ import { getCliName } from '../utils/cli-name.ts';
11
14
  import { extractCodeBlocks } from '../utils/code-extractor.js';
12
15
  import { HooksRunner } from '../utils/hooks-runner.ts';
13
16
  import { createDebug, pluralize, tag } from '../utils/logger.js';
14
17
  import { loop, pause } from '../utils/loop.js';
15
18
  import { RulesLoader } from '../utils/rules-loader.ts';
19
+ import { normalizeInlineText } from '../utils/strings.ts';
16
20
  import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
17
21
  import type { Agent, AgentDeps } from './agent.js';
18
22
  import type { Conversation } from './conversation.js';
@@ -32,6 +36,7 @@ class Navigator implements Agent {
32
36
  private hooksRunner: HooksRunner;
33
37
 
34
38
  private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
39
+ lastFailureReason: string | null = null;
35
40
 
36
41
  private systemPrompt = dedent`
37
42
  <role>
@@ -104,8 +109,12 @@ class Navigator implements Agent {
104
109
  private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string {
105
110
  const currentState = stateManager.getCurrentState();
106
111
  if (!currentState) return '';
107
- const current = /^https?:\/\//i.test(expectedUrl) ? currentState.fullUrl || currentState.url || '' : currentState.url || '';
108
- return current;
112
+ return this.comparableUrl(currentState, expectedUrl);
113
+ }
114
+
115
+ private comparableUrl(state: { url?: string; fullUrl?: string }, expectedUrl: string): string {
116
+ if (/^https?:\/\//i.test(expectedUrl)) return state.fullUrl || state.url || '';
117
+ return state.url || '';
109
118
  }
110
119
 
111
120
  private isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean {
@@ -161,7 +170,7 @@ class Navigator implements Agent {
161
170
 
162
171
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
163
172
  if (!resolved) {
164
- throw new Error(`Navigation to ${url} failed: redirected to ${actualPath} and could not resolve`);
173
+ throw this.navigationError(url, `redirected to ${actualPath} and could not resolve`);
165
174
  }
166
175
  } else if (action.lastError) {
167
176
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
@@ -173,7 +182,7 @@ class Navigator implements Agent {
173
182
 
174
183
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
175
184
  if (!resolved) {
176
- throw new Error(`Navigation to ${url} failed: ${action.lastError?.message}`);
185
+ throw this.navigationError(url, action.lastError?.message || 'Navigation failed');
177
186
  }
178
187
  }
179
188
  await this.explorer.capture({ screenshot: true });
@@ -189,9 +198,25 @@ class Navigator implements Agent {
189
198
  }
190
199
  }
191
200
 
201
+ private navigationError(url: string, fallback: string): Error {
202
+ if (this.lastFailureReason) return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
203
+ return new Error(`Navigation to ${url} failed: ${fallback}`);
204
+ }
205
+
206
+ private failureReason(stopReason: string | null, knowledge: string, url: string): string | null {
207
+ const reasons: string[] = [];
208
+ if (stopReason) reasons.push(stopReason);
209
+ if (!knowledge) {
210
+ const path = extractStatePath(url).split('?')[0].split('#')[0];
211
+ reasons.push(`no knowledge is set for ${path} — teach it what this page needs (credentials, hints) with: ${getCliName()} learn "${path}" "<facts>"`);
212
+ }
213
+ return reasons.join('; ') || null;
214
+ }
215
+
192
216
  async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
193
217
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
194
218
 
219
+ this.lastFailureReason = null;
195
220
  tag('info').log('AI Navigator resolving state at', actionResult.url);
196
221
  debugLog('Resolution message:', message);
197
222
 
@@ -199,52 +224,9 @@ class Navigator implements Agent {
199
224
  const expectedUrl = opts?.expectedUrl;
200
225
 
201
226
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
202
- let experience = '';
203
-
204
- if (!actionResult.isInsideIframe) {
205
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
206
- if (successful.length > 0) {
207
- tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
208
- experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
209
- }
210
- }
211
-
212
- const prompt = dedent`
213
- <message>
214
- ${message}
215
- </message>
216
-
217
- <page>
218
- ${actionResult.toAiContext()}
219
-
220
- <page_html>
221
- ${await actionResult.combinedHtml()}
222
- </page_html>
223
- </page>
224
-
225
- <task>
226
- Identify the actual request of the user.
227
- Identify what is expected by user.
228
- Identify what might have caused the error.
229
- Propose different solutions to achieve the result.
230
- Solution should be valid CodeceptJS code.
231
- Use only data from the <page> context to plan the solution.
232
- Try various ways to achieve the result
233
- </task>
234
-
235
- ${actionRule}
236
-
237
- ${unexpectedPopupRule}
238
-
239
- ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
240
-
241
- ${experience}
242
-
243
- ${knowledge}
244
- `;
245
227
 
246
228
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
247
- conversation.addUserText(prompt);
229
+ conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
248
230
 
249
231
  let stopReason: string | null = null;
250
232
  const tools = {
@@ -272,8 +254,9 @@ class Navigator implements Agent {
272
254
  let htmlContextAdded = false;
273
255
  let codeBlockIndex = 0;
274
256
  let totalAttempts = 0;
257
+ let lastFailure: string | null = null;
275
258
  const progressBlocks: string[] = [];
276
- const batchFailures: Array<{ code: string; error: string; ariaChanges?: string | null; urlAfter?: string }> = [];
259
+ const batchFailures: BatchFailure[] = [];
277
260
 
278
261
  let resolved = false;
279
262
  await loop(
@@ -282,7 +265,6 @@ class Navigator implements Agent {
282
265
  const result = await this.provider.invokeConversation(conversation, tools);
283
266
  if (!result) return;
284
267
  if (stopReason) {
285
- tag('error').log(`Navigator stopped: ${stopReason}`);
286
268
  resolved = false;
287
269
  stop();
288
270
  return;
@@ -306,41 +288,8 @@ class Navigator implements Agent {
306
288
  return;
307
289
  }
308
290
  tag('operation').log('Feeding failures back to AI for a new batch...');
309
- let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
310
- if (batchFailures.length > 0) {
311
- const lines = batchFailures
312
- .map((f) => {
313
- const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
314
- if (!f.ariaChanges) return head;
315
- const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
316
- return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
317
- })
318
- .join('\n');
319
- contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
320
- }
321
- if (!htmlContextAdded) {
322
- htmlContextAdded = true;
323
- contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
324
- }
325
- const pageReacted = batchFailures.some((f) => f.ariaChanges);
326
- if (pageReacted) {
327
- contextMsg += dedent`
328
- Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
329
-
330
- Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
331
-
332
- Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
333
-
334
- A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
335
-
336
- B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
337
-
338
- C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
339
- `;
340
- } else {
341
- contextMsg += 'Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.';
342
- }
343
- conversation.addUserText(contextMsg);
291
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
292
+ htmlContextAdded = true;
344
293
  codeBlocks = [];
345
294
  batchFailures.length = 0;
346
295
  return;
@@ -348,114 +297,59 @@ class Navigator implements Agent {
348
297
  codeBlockIndex++;
349
298
  totalAttempts++;
350
299
 
351
- await action.exitIframe();
352
-
353
300
  const prevActionResult = action.actionResult ?? actionResult;
354
301
  const prevHash = prevActionResult.getStateHash();
355
302
 
356
- debugLog(`Attempting resolution: ${codeBlock}`);
357
- const attemptOk = await action.attempt(codeBlock, message);
358
-
359
- const page = action.playwrightHelper?.page;
360
- if (page) {
361
- try {
362
- await page.waitForLoadState('load', { timeout: 5000 });
363
- } catch {
364
- // Navigation did not reach 'load' state within timeout; continue and verify URL
365
- }
366
- }
367
-
368
- if (attemptOk) opts?.onAttempt?.({ code: codeBlock });
369
-
370
- if (!attemptOk) {
371
- const raw = action.lastError?.message || 'attempt failed';
372
- const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
373
- const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
374
- batchFailures.push({ code: codeBlock, error: shortErr });
375
- opts?.onAttempt?.({ code: codeBlock, error: shortErr });
303
+ const attempt = await this.executeAttempt(action, codeBlock, message);
304
+ opts?.onAttempt?.({ code: codeBlock, error: attempt.error });
305
+ if (attempt.error) {
306
+ batchFailures.push({ code: codeBlock, error: attempt.error });
307
+ lastFailure = attempt.error;
376
308
  }
377
309
 
378
310
  if (expectedUrl) {
379
- if (page) {
380
- try {
381
- await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
382
- } catch {
383
- // URL did not transition to expectedUrl within timeout
384
- }
385
- }
386
- const freshState = await this.explorer.capture();
387
- const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
388
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
389
- const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
390
- resolved = urlMatches && stateChanged;
391
-
392
- if (!resolved && attemptOk) {
393
- let ariaChanges: string | null = null;
394
- if (freshState.getStateHash() !== prevHash) {
395
- try {
396
- const diff = await freshState.diff(prevActionResult);
397
- ariaChanges = diff.ariaChanged;
398
- } catch (err) {
399
- debugLog('Failed to compute pageDiff for failed URL verification:', err);
400
- }
401
- }
311
+ const check = await this.verifyNavigation(action, expectedUrl);
312
+ const freshHash = check.freshState.getStateHash();
313
+ resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
314
+
315
+ if (!resolved && attempt.ok) {
316
+ lastFailure = `URL did not change (still ${check.freshState.url})`;
402
317
  batchFailures.push({
403
318
  code: codeBlock,
404
- error: `URL did not change (still ${freshState.url})`,
405
- ariaChanges,
406
- urlAfter: freshState.url,
319
+ error: lastFailure,
320
+ ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
321
+ urlAfter: check.freshState.url,
407
322
  });
408
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
323
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
409
324
  }
410
- if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
325
+ if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
411
326
  progressBlocks.push(codeBlock);
412
327
  }
413
328
  } else {
414
- resolved = attemptOk;
415
- if (attemptOk) progressBlocks.push(codeBlock);
329
+ resolved = attempt.ok;
330
+ if (attempt.ok) progressBlocks.push(codeBlock);
416
331
  }
417
332
 
418
- if (resolved) {
419
- tag('success').log('Navigation resolved successfully');
420
- let scenario = message.split('\n')[0];
421
- if (expectedUrl) {
422
- const fromPath = extractStatePath(actionResult.url || '');
423
- const toPath = extractStatePath(expectedUrl);
424
- scenario = `reach ${toPath} from ${fromPath}`;
425
- }
426
- const recipe = progressBlocks
427
- .join('\n')
428
- .split('\n')
429
- .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
430
- .join('\n')
431
- .trim();
432
- if (recipe) {
433
- const body = `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`;
434
- this.experienceTracker.writeFlow(actionResult, body);
435
- }
436
- stop();
437
- return;
438
- }
333
+ if (!resolved) return;
334
+
335
+ tag('success').log('Navigation resolved successfully');
336
+ this.saveFlow(message, expectedUrl, actionResult, progressBlocks);
337
+ stop();
439
338
  },
440
339
  {
441
340
  maxAttempts: this.MAX_ATTEMPTS * 2,
442
341
  observability: {
443
342
  agent: 'navigator',
444
343
  },
445
- catch: async (error) => {
344
+ catch: async ({ error }) => {
345
+ if (isFatalBrowserError(error)) throw error;
446
346
  debugLog(error);
447
347
  resolved = false;
448
348
  },
449
349
  }
450
350
  );
451
351
 
452
- if (!resolved && expectedUrl) {
453
- await (action.getActor() as any).wait(1);
454
- if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
455
- resolved = true;
456
- tag('success').log('Navigation resolved after delayed redirect');
457
- }
458
- }
352
+ if (!resolved && expectedUrl) resolved = await this.rescueDelayedRedirect(action, expectedUrl);
459
353
 
460
354
  if (!resolved && stopReason) {
461
355
  tag('error').log(`Navigator stopped: ${stopReason}`);
@@ -463,24 +357,192 @@ class Navigator implements Agent {
463
357
  tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
464
358
  }
465
359
 
466
- if (!resolved && isInteractive()) {
467
- const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
468
- const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
360
+ if (!resolved && isInteractive()) resolved = await this.askUserToResolve(action, message, expectedUrl, stopReason);
469
361
 
470
- if (userInput?.trim()) {
471
- resolved = await action.attempt(userInput, message);
472
- if (resolved && expectedUrl) {
473
- await (action.getActor() as any).wait(1);
474
- if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) {
475
- resolved = false;
476
- }
477
- }
478
- }
362
+ if (!resolved) {
363
+ let cause = 'the AI proposed no working solution for this page';
364
+ if (lastFailure) cause = `${totalAttempts} ${pluralize(totalAttempts, 'attempt')} failed, last: ${lastFailure}`;
365
+ if (stopReason) cause = stopReason;
366
+ this.lastFailureReason = this.failureReason(cause, knowledge, actionResult.url || '');
479
367
  }
480
368
 
481
369
  return resolved;
482
370
  }
483
371
 
372
+ private async buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string> {
373
+ let experience = '';
374
+ if (!actionResult.isInsideIframe) {
375
+ const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
376
+ if (successful.length > 0) {
377
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
378
+ experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
379
+ }
380
+ }
381
+
382
+ return dedent`
383
+ <message>
384
+ ${message}
385
+ </message>
386
+
387
+ <page>
388
+ ${actionResult.toAiContext()}
389
+
390
+ <page_html>
391
+ ${await actionResult.combinedHtml()}
392
+ </page_html>
393
+ </page>
394
+
395
+ <task>
396
+ Identify the actual request of the user.
397
+ Identify what is expected by user.
398
+ Identify what might have caused the error.
399
+ Propose different solutions to achieve the result.
400
+ Solution should be valid CodeceptJS code.
401
+ Use only data from the <page> context to plan the solution.
402
+ Try various ways to achieve the result
403
+ </task>
404
+
405
+ ${actionRule}
406
+
407
+ ${unexpectedPopupRule}
408
+
409
+ ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
410
+
411
+ ${experience}
412
+
413
+ ${this.knowledgeTracker.renderRelevantContext(actionResult)}
414
+ `;
415
+ }
416
+
417
+ private async buildRetryFeedback(failures: BatchFailure[], includeHtml: boolean, actionResult: ActionResult): Promise<string> {
418
+ let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
419
+
420
+ if (failures.length > 0) {
421
+ const lines = failures
422
+ .map((f) => {
423
+ const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
424
+ if (!f.ariaChanges) return head;
425
+ const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
426
+ return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
427
+ })
428
+ .join('\n');
429
+ contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
430
+ }
431
+
432
+ if (includeHtml) {
433
+ contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
434
+ }
435
+
436
+ if (!failures.some((f) => f.ariaChanges)) {
437
+ return `${contextMsg}Propose new solutions. If errors mention "intercepts pointer events" or timeouts on visible elements, an overlay is blocking — dismiss it first (Escape, click outside, Close button) before retrying the original action.`;
438
+ }
439
+
440
+ return (
441
+ contextMsg +
442
+ dedent`
443
+ Some steps in the previous batch did not throw, but the URL did not change to the expected target and the page changed in other ways — the ARIA diff for each such step is listed in <previous_failures> above.
444
+
445
+ Read those diffs and judge what each step actually triggered. Different action types produce different reactions; the diff is your only evidence of what happened. A diff might show, for example: a new alert / alertdialog / status / validation message appearing near a field or at page level; a modal, dialog, or wizard step opening; a banner, toast, or notification region appearing; a section expanding or collapsing; a tab or accordion switching content. A diff might also be empty or unrelated to the step — that is also a signal.
446
+
447
+ Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
448
+
449
+ A. The diff indicates the application requires something only the user can supply — for example: an authentication failure you cannot guess past, a captcha, a permission the test cannot satisfy, or knowledge that is not present in the provided context. Call the stop() tool and quote what you saw in the diff and what is needed.
450
+
451
+ B. The diff indicates the next step is something you can perform from the existing knowledge / hint context — for example: re-emit a step with a value that exists in the knowledge but was used incorrectly; dismiss an unexpected modal; accept a confirmation; take a follow-up step the page now requires. Emit code blocks for that next step. Do NOT change the locator of a step that already produced a reaction.
452
+
453
+ C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
454
+ `
455
+ );
456
+ }
457
+
458
+ private async executeAttempt(action: Action, codeBlock: string, message: string): Promise<{ ok: boolean; error?: string }> {
459
+ await action.exitIframe();
460
+
461
+ debugLog(`Attempting resolution: ${codeBlock}`);
462
+ const ok = await action.attempt(codeBlock, message);
463
+
464
+ const page = action.playwrightHelper?.page;
465
+ if (page) {
466
+ try {
467
+ await page.waitForLoadState('load', { timeout: 5000 });
468
+ } catch {
469
+ // Navigation did not reach 'load' state within timeout; continue and verify URL
470
+ }
471
+ }
472
+
473
+ if (ok) return { ok };
474
+
475
+ const raw = action.lastError?.message || 'attempt failed';
476
+ const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
477
+ return { ok, error: normalizeInlineText(firstMeaningful).slice(0, 220) || 'attempt failed' };
478
+ }
479
+
480
+ private async verifyNavigation(action: Action, expectedUrl: string): Promise<{ freshState: ActionResult; urlMatches: boolean }> {
481
+ const page = action.playwrightHelper?.page;
482
+ if (page) {
483
+ try {
484
+ await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
485
+ } catch {
486
+ // URL did not transition to expectedUrl within timeout
487
+ }
488
+ }
489
+
490
+ const freshState = await this.explorer.capture();
491
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
492
+
493
+ return { freshState, urlMatches };
494
+ }
495
+
496
+ private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
497
+ if (freshState.getStateHash() === previous.getStateHash()) return null;
498
+ try {
499
+ const diff = await freshState.diff(previous);
500
+ return diff.ariaChanged;
501
+ } catch (err) {
502
+ debugLog('Failed to compute pageDiff for failed URL verification:', err);
503
+ return null;
504
+ }
505
+ }
506
+
507
+ private saveFlow(message: string, expectedUrl: string | undefined, actionResult: ActionResult, progressBlocks: string[]): void {
508
+ let scenario = message.split('\n')[0];
509
+ if (expectedUrl) {
510
+ scenario = `reach ${extractStatePath(expectedUrl)} from ${extractStatePath(actionResult.url || '')}`;
511
+ }
512
+
513
+ const recipe = progressBlocks
514
+ .join('\n')
515
+ .split('\n')
516
+ .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
517
+ .join('\n')
518
+ .trim();
519
+ if (!recipe) return;
520
+
521
+ this.experienceTracker.writeFlow(actionResult, `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`);
522
+ }
523
+
524
+ private async rescueDelayedRedirect(action: Action, expectedUrl: string): Promise<boolean> {
525
+ await (action.getActor() as any).wait(1);
526
+ if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) return false;
527
+ tag('success').log('Navigation resolved after delayed redirect');
528
+ return true;
529
+ }
530
+
531
+ private async askUserToResolve(action: Action, message: string, expectedUrl: string | undefined, stopReason: string | null): Promise<boolean> {
532
+ let stopLine = '';
533
+ if (stopReason) stopLine = `Navigator stopped: ${stopReason}\n`;
534
+
535
+ const userInput = await pause(`${stopLine}Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\nTarget: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
536
+ if (!userInput?.trim()) return false;
537
+
538
+ const resolved = await action.attempt(userInput, message);
539
+ if (!resolved) return false;
540
+ if (!expectedUrl) return true;
541
+
542
+ await (action.getActor() as any).wait(1);
543
+ return this.isOnExpectedPage(expectedUrl, action.stateManager);
544
+ }
545
+
484
546
  private buildExperienceTools(): { learnExperience: unknown } | undefined {
485
547
  if (!this.experienceTracker) return undefined;
486
548
  const stateManager = this.stateManager;
@@ -620,7 +682,7 @@ class Navigator implements Agent {
620
682
  return suggestion;
621
683
  }
622
684
 
623
- async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number }> {
685
+ async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; inexpressible: boolean; results: AssertionResult[]; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number }> {
624
686
  tag('info').log('AI Navigator verifying state at', actionResult.url);
625
687
  debugLog('Verification message:', message);
626
688
 
@@ -698,6 +760,7 @@ class Navigator implements Agent {
698
760
 
699
761
  let codeBlocks: string[] = [];
700
762
  const successfulCodes: string[] = [];
763
+ const results: AssertionResult[] = [];
701
764
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
702
765
 
703
766
  const action = this.explorer.action();
@@ -739,6 +802,8 @@ class Navigator implements Agent {
739
802
  await action.exitIframe();
740
803
 
741
804
  const verified = await action.attempt(codeBlock, message);
805
+ const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
806
+ results.push({ code: codeBlock, passed: verified, proof });
742
807
 
743
808
  if (verified) {
744
809
  tag('success').log('Verification passed');
@@ -747,12 +812,6 @@ class Navigator implements Agent {
747
812
  } else {
748
813
  failures++;
749
814
  }
750
-
751
- const target = Math.min(codeBlocks.length, this.verifyAttempts);
752
- const majorityNeeded = Math.floor(target / 2) + 1;
753
- if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
754
- stop();
755
- }
756
815
  },
757
816
  {
758
817
  maxAttempts: this.verifyAttempts,
@@ -773,10 +832,16 @@ class Navigator implements Agent {
773
832
  let verified = successfulCodes.length >= majorityNeeded;
774
833
  if (alreadyVerified) verified = true;
775
834
 
835
+ const inexpressible = !alreadyVerified && totalAttempted === 0;
836
+ if (inexpressible) {
837
+ tag('warning').log('No assertion could express this claim');
838
+ return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
839
+ }
840
+
776
841
  actionResult.addVerification(message, verified);
777
842
  this.stateManager.updateState(actionResult);
778
843
 
779
- return { verified, successfulCodes, assertionSteps, totalAttempted };
844
+ return { verified, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
780
845
  }
781
846
 
782
847
  private checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean {
@@ -787,4 +852,8 @@ class Navigator implements Agent {
787
852
  }
788
853
  }
789
854
 
855
+ type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
856
+
857
+ export type AssertionResult = { code: string; passed: boolean; proof: string[] };
858
+
790
859
  export { Navigator };