explorbot 0.2.4 → 0.3.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 (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -4,11 +4,14 @@ import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
5
  import { normalizeUrl } from '../state-manager.js';
6
6
  import { renderAssertion } from "../playwright-recorder.js";
7
+ import { isFatalBrowserError } from "../utils/browser-errors.js";
8
+ import { getCliName } from "../utils/cli-name.js";
7
9
  import { extractCodeBlocks } from '../utils/code-extractor.js';
8
10
  import { HooksRunner } from "../utils/hooks-runner.js";
9
11
  import { createDebug, pluralize, tag } from '../utils/logger.js';
10
12
  import { loop, pause } from '../utils/loop.js';
11
13
  import { RulesLoader } from "../utils/rules-loader.js";
14
+ import { normalizeInlineText } from "../utils/strings.js";
12
15
  import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
13
16
  import { Researcher } from "./researcher.js";
14
17
  import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
@@ -22,6 +25,7 @@ class Navigator {
22
25
  experienceTracker;
23
26
  hooksRunner;
24
27
  MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
28
+ lastFailureReason = null;
25
29
  systemPrompt = dedent `
26
30
  <role>
27
31
  You are senior test automation engineer with master QA skills.
@@ -90,8 +94,12 @@ class Navigator {
90
94
  const currentState = stateManager.getCurrentState();
91
95
  if (!currentState)
92
96
  return '';
93
- const current = /^https?:\/\//i.test(expectedUrl) ? currentState.fullUrl || currentState.url || '' : currentState.url || '';
94
- return current;
97
+ return this.comparableUrl(currentState, expectedUrl);
98
+ }
99
+ comparableUrl(state, expectedUrl) {
100
+ if (/^https?:\/\//i.test(expectedUrl))
101
+ return state.fullUrl || state.url || '';
102
+ return state.url || '';
95
103
  }
96
104
  isSameExpectedOrigin(expectedUrl, stateManager) {
97
105
  const currentState = stateManager.getCurrentState();
@@ -139,7 +147,7 @@ class Navigator {
139
147
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
140
148
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
141
149
  if (!resolved) {
142
- throw new Error(`Navigation to ${url} failed: redirected to ${actualPath} and could not resolve`);
150
+ throw this.navigationError(url, `redirected to ${actualPath} and could not resolve`);
143
151
  }
144
152
  }
145
153
  else if (action.lastError) {
@@ -151,7 +159,7 @@ class Navigator {
151
159
  `.trim();
152
160
  const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
153
161
  if (!resolved) {
154
- throw new Error(`Navigation to ${url} failed: ${action.lastError?.message}`);
162
+ throw this.navigationError(url, action.lastError?.message || 'Navigation failed');
155
163
  }
156
164
  }
157
165
  await this.explorer.capture({ screenshot: true });
@@ -167,57 +175,32 @@ class Navigator {
167
175
  throw error;
168
176
  }
169
177
  }
178
+ navigationError(url, fallback) {
179
+ if (this.lastFailureReason)
180
+ return new Error(`Navigation to ${url} failed: ${this.lastFailureReason}`);
181
+ return new Error(`Navigation to ${url} failed: ${fallback}`);
182
+ }
183
+ failureReason(stopReason, knowledge, url) {
184
+ const reasons = [];
185
+ if (stopReason)
186
+ reasons.push(stopReason);
187
+ if (!knowledge) {
188
+ const path = extractStatePath(url).split('?')[0].split('#')[0];
189
+ reasons.push(`no knowledge is set for ${path} — teach it what this page needs (credentials, hints) with: ${getCliName()} learn "${path}" "<facts>"`);
190
+ }
191
+ return reasons.join('; ') || null;
192
+ }
170
193
  async resolveState(message, actionResult, opts) {
171
194
  if (!this.provider)
172
195
  throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
196
+ this.lastFailureReason = null;
173
197
  tag('info').log('AI Navigator resolving state at', actionResult.url);
174
198
  debugLog('Resolution message:', message);
175
199
  const action = opts?.action ?? this.explorer.action();
176
200
  const expectedUrl = opts?.expectedUrl;
177
201
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
178
- let experience = '';
179
- if (!actionResult.isInsideIframe) {
180
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
181
- if (successful.length > 0) {
182
- tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
183
- 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>`;
184
- }
185
- }
186
- const prompt = dedent `
187
- <message>
188
- ${message}
189
- </message>
190
-
191
- <page>
192
- ${actionResult.toAiContext()}
193
-
194
- <page_html>
195
- ${await actionResult.combinedHtml()}
196
- </page_html>
197
- </page>
198
-
199
- <task>
200
- Identify the actual request of the user.
201
- Identify what is expected by user.
202
- Identify what might have caused the error.
203
- Propose different solutions to achieve the result.
204
- Solution should be valid CodeceptJS code.
205
- Use only data from the <page> context to plan the solution.
206
- Try various ways to achieve the result
207
- </task>
208
-
209
- ${actionRule}
210
-
211
- ${unexpectedPopupRule}
212
-
213
- ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
214
-
215
- ${experience}
216
-
217
- ${knowledge}
218
- `;
219
202
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
220
- conversation.addUserText(prompt);
203
+ conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
221
204
  let stopReason = null;
222
205
  const tools = {
223
206
  stop: tool({
@@ -243,6 +226,7 @@ class Navigator {
243
226
  let htmlContextAdded = false;
244
227
  let codeBlockIndex = 0;
245
228
  let totalAttempts = 0;
229
+ let lastFailure = null;
246
230
  const progressBlocks = [];
247
231
  const batchFailures = [];
248
232
  let resolved = false;
@@ -252,7 +236,6 @@ class Navigator {
252
236
  if (!result)
253
237
  return;
254
238
  if (stopReason) {
255
- tag('error').log(`Navigator stopped: ${stopReason}`);
256
239
  resolved = false;
257
240
  stop();
258
241
  return;
@@ -274,173 +257,238 @@ class Navigator {
274
257
  return;
275
258
  }
276
259
  tag('operation').log('Feeding failures back to AI for a new batch...');
277
- let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
278
- if (batchFailures.length > 0) {
279
- const lines = batchFailures
280
- .map((f) => {
281
- const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
282
- if (!f.ariaChanges)
283
- return head;
284
- const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
285
- return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
286
- })
287
- .join('\n');
288
- contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
289
- }
290
- if (!htmlContextAdded) {
291
- htmlContextAdded = true;
292
- contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
293
- }
294
- const pageReacted = batchFailures.some((f) => f.ariaChanges);
295
- if (pageReacted) {
296
- contextMsg += dedent `
297
- 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.
298
-
299
- 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.
300
-
301
- Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
302
-
303
- 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.
304
-
305
- 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.
306
-
307
- C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
308
- `;
309
- }
310
- else {
311
- 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.';
312
- }
313
- conversation.addUserText(contextMsg);
260
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
261
+ htmlContextAdded = true;
314
262
  codeBlocks = [];
315
263
  batchFailures.length = 0;
316
264
  return;
317
265
  }
318
266
  codeBlockIndex++;
319
267
  totalAttempts++;
320
- await action.exitIframe();
321
268
  const prevActionResult = action.actionResult ?? actionResult;
322
269
  const prevHash = prevActionResult.getStateHash();
323
- debugLog(`Attempting resolution: ${codeBlock}`);
324
- const attemptOk = await action.attempt(codeBlock, message);
325
- const page = action.playwrightHelper?.page;
326
- if (page) {
327
- try {
328
- await page.waitForLoadState('load', { timeout: 5000 });
329
- }
330
- catch {
331
- // Navigation did not reach 'load' state within timeout; continue and verify URL
332
- }
333
- }
334
- if (attemptOk)
335
- opts?.onAttempt?.({ code: codeBlock });
336
- if (!attemptOk) {
337
- const raw = action.lastError?.message || 'attempt failed';
338
- const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
339
- const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
340
- batchFailures.push({ code: codeBlock, error: shortErr });
341
- opts?.onAttempt?.({ code: codeBlock, error: shortErr });
270
+ const attempt = await this.executeAttempt(action, codeBlock, message);
271
+ opts?.onAttempt?.({ code: codeBlock, error: attempt.error });
272
+ if (attempt.error) {
273
+ batchFailures.push({ code: codeBlock, error: attempt.error });
274
+ lastFailure = attempt.error;
342
275
  }
343
276
  if (expectedUrl) {
344
- if (page) {
345
- try {
346
- await page.waitForURL((url) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
347
- }
348
- catch {
349
- // URL did not transition to expectedUrl within timeout
350
- }
351
- }
352
- const freshState = await this.explorer.capture();
353
- const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
354
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
355
- const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
356
- resolved = urlMatches && stateChanged;
357
- if (!resolved && attemptOk) {
358
- let ariaChanges = null;
359
- if (freshState.getStateHash() !== prevHash) {
360
- try {
361
- const diff = await freshState.diff(prevActionResult);
362
- ariaChanges = diff.ariaChanged;
363
- }
364
- catch (err) {
365
- debugLog('Failed to compute pageDiff for failed URL verification:', err);
366
- }
367
- }
277
+ const check = await this.verifyNavigation(action, expectedUrl);
278
+ const freshHash = check.freshState.getStateHash();
279
+ resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
280
+ if (!resolved && attempt.ok) {
281
+ lastFailure = `URL did not change (still ${check.freshState.url})`;
368
282
  batchFailures.push({
369
283
  code: codeBlock,
370
- error: `URL did not change (still ${freshState.url})`,
371
- ariaChanges,
372
- urlAfter: freshState.url,
284
+ error: lastFailure,
285
+ ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
286
+ urlAfter: check.freshState.url,
373
287
  });
374
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
288
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
375
289
  }
376
- if (freshState.getStateHash() !== prevHash && (attemptOk || urlMatches)) {
290
+ if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
377
291
  progressBlocks.push(codeBlock);
378
292
  }
379
293
  }
380
294
  else {
381
- resolved = attemptOk;
382
- if (attemptOk)
295
+ resolved = attempt.ok;
296
+ if (attempt.ok)
383
297
  progressBlocks.push(codeBlock);
384
298
  }
385
- if (resolved) {
386
- tag('success').log('Navigation resolved successfully');
387
- let scenario = message.split('\n')[0];
388
- if (expectedUrl) {
389
- const fromPath = extractStatePath(actionResult.url || '');
390
- const toPath = extractStatePath(expectedUrl);
391
- scenario = `reach ${toPath} from ${fromPath}`;
392
- }
393
- const recipe = progressBlocks
394
- .join('\n')
395
- .split('\n')
396
- .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
397
- .join('\n')
398
- .trim();
399
- if (recipe) {
400
- const body = `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`;
401
- this.experienceTracker.writeFlow(actionResult, body);
402
- }
403
- stop();
299
+ if (!resolved)
404
300
  return;
405
- }
301
+ tag('success').log('Navigation resolved successfully');
302
+ this.saveFlow(message, expectedUrl, actionResult, progressBlocks);
303
+ stop();
406
304
  }, {
407
305
  maxAttempts: this.MAX_ATTEMPTS * 2,
408
306
  observability: {
409
307
  agent: 'navigator',
410
308
  },
411
- catch: async (error) => {
309
+ catch: async ({ error }) => {
310
+ if (isFatalBrowserError(error))
311
+ throw error;
412
312
  debugLog(error);
413
313
  resolved = false;
414
314
  },
415
315
  });
416
- if (!resolved && expectedUrl) {
417
- await action.getActor().wait(1);
418
- if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
419
- resolved = true;
420
- tag('success').log('Navigation resolved after delayed redirect');
421
- }
422
- }
316
+ if (!resolved && expectedUrl)
317
+ resolved = await this.rescueDelayedRedirect(action, expectedUrl);
423
318
  if (!resolved && stopReason) {
424
319
  tag('error').log(`Navigator stopped: ${stopReason}`);
425
320
  }
426
321
  else if (!resolved && totalAttempts > 0) {
427
322
  tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
428
323
  }
429
- if (!resolved && isInteractive()) {
430
- const stopLine = stopReason ? `Navigator stopped: ${stopReason}\n` : '';
431
- 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):`);
432
- if (userInput?.trim()) {
433
- resolved = await action.attempt(userInput, message);
434
- if (resolved && expectedUrl) {
435
- await action.getActor().wait(1);
436
- if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) {
437
- resolved = false;
438
- }
439
- }
440
- }
324
+ if (!resolved && isInteractive())
325
+ resolved = await this.askUserToResolve(action, message, expectedUrl, stopReason);
326
+ if (!resolved) {
327
+ let cause = 'the AI proposed no working solution for this page';
328
+ if (lastFailure)
329
+ cause = `${totalAttempts} ${pluralize(totalAttempts, 'attempt')} failed, last: ${lastFailure}`;
330
+ if (stopReason)
331
+ cause = stopReason;
332
+ this.lastFailureReason = this.failureReason(cause, knowledge, actionResult.url || '');
441
333
  }
442
334
  return resolved;
443
335
  }
336
+ async buildResolutionPrompt(message, actionResult, injectedExperience) {
337
+ let experience = injectedExperience || '';
338
+ if (!experience && !actionResult.isInsideIframe) {
339
+ experience = this.experienceTracker.renderExperienceFor(actionResult);
340
+ }
341
+ return dedent `
342
+ <message>
343
+ ${message}
344
+ </message>
345
+
346
+ <page>
347
+ ${actionResult.toAiContext()}
348
+
349
+ <page_html>
350
+ ${await actionResult.combinedHtml()}
351
+ </page_html>
352
+ </page>
353
+
354
+ <task>
355
+ Identify the actual request of the user.
356
+ Identify what is expected by user.
357
+ Identify what might have caused the error.
358
+ Propose different solutions to achieve the result.
359
+ Solution should be valid CodeceptJS code.
360
+ Use only data from the <page> context to plan the solution.
361
+ Try various ways to achieve the result
362
+ </task>
363
+
364
+ ${actionRule}
365
+
366
+ ${unexpectedPopupRule}
367
+
368
+ ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
369
+
370
+ ${experience}
371
+
372
+ ${this.knowledgeTracker.renderRelevantContext(actionResult)}
373
+ `;
374
+ }
375
+ async buildRetryFeedback(failures, includeHtml, actionResult) {
376
+ let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
377
+ if (failures.length > 0) {
378
+ const lines = failures
379
+ .map((f) => {
380
+ const head = `- \`${f.code.split('\n')[0]}\` → ${f.error}`;
381
+ if (!f.ariaChanges)
382
+ return head;
383
+ const trimmed = f.ariaChanges.split('\n').slice(0, 12).join('\n ');
384
+ return `${head}\n • ARIA changes after the action:\n ${trimmed}`;
385
+ })
386
+ .join('\n');
387
+ contextMsg += `<previous_failures>\n${lines}\n</previous_failures>\n\n`;
388
+ }
389
+ if (includeHtml) {
390
+ contextMsg += `Full HTML context:\n\n<page_html>\n${await actionResult.combinedHtml()}\n</page_html>\n\n`;
391
+ }
392
+ if (!failures.some((f) => f.ariaChanges)) {
393
+ 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.`;
394
+ }
395
+ return (contextMsg +
396
+ dedent `
397
+ 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.
398
+
399
+ 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.
400
+
401
+ Choose exactly ONE path based on what the diffs actually show — do not assume the previous step submitted any particular kind of data:
402
+
403
+ 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.
404
+
405
+ 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.
406
+
407
+ C. The diff is empty or unrelated to your step — the action likely missed its target. Propose a different locator strategy.
408
+ `);
409
+ }
410
+ async executeAttempt(action, codeBlock, message) {
411
+ await action.exitIframe();
412
+ debugLog(`Attempting resolution: ${codeBlock}`);
413
+ const ok = await action.attempt(codeBlock, message);
414
+ const page = action.playwrightHelper?.page;
415
+ if (page) {
416
+ try {
417
+ await page.waitForLoadState('load', { timeout: 5000 });
418
+ }
419
+ catch {
420
+ // Navigation did not reach 'load' state within timeout; continue and verify URL
421
+ }
422
+ }
423
+ if (ok)
424
+ return { ok };
425
+ const raw = action.lastError?.message || 'attempt failed';
426
+ const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
427
+ return { ok, error: normalizeInlineText(firstMeaningful).slice(0, 220) || 'attempt failed' };
428
+ }
429
+ async verifyNavigation(action, expectedUrl) {
430
+ const page = action.playwrightHelper?.page;
431
+ if (page) {
432
+ try {
433
+ await page.waitForURL((url) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
434
+ }
435
+ catch {
436
+ // URL did not transition to expectedUrl within timeout
437
+ }
438
+ }
439
+ const freshState = await this.explorer.capture();
440
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
441
+ return { freshState, urlMatches };
442
+ }
443
+ async ariaDiff(freshState, previous) {
444
+ if (freshState.getStateHash() === previous.getStateHash())
445
+ return null;
446
+ try {
447
+ const diff = await freshState.diff(previous);
448
+ return diff.ariaChanged;
449
+ }
450
+ catch (err) {
451
+ debugLog('Failed to compute pageDiff for failed URL verification:', err);
452
+ return null;
453
+ }
454
+ }
455
+ saveFlow(message, expectedUrl, actionResult, progressBlocks) {
456
+ let scenario = message.split('\n')[0];
457
+ if (expectedUrl) {
458
+ scenario = `reach ${extractStatePath(expectedUrl)} from ${extractStatePath(actionResult.url || '')}`;
459
+ }
460
+ const recipe = progressBlocks
461
+ .join('\n')
462
+ .split('\n')
463
+ .filter((line) => !/^\s*I\.amOnPage\s*\(/.test(line))
464
+ .join('\n')
465
+ .trim();
466
+ if (!recipe)
467
+ return;
468
+ this.experienceTracker.writeFlow(actionResult, `## FLOW: ${scenario}\n\n* ${scenario}\n\n\`\`\`js\n${recipe}\n\`\`\`\n\n---\n`);
469
+ }
470
+ async rescueDelayedRedirect(action, expectedUrl) {
471
+ await action.getActor().wait(1);
472
+ if (!this.isOnExpectedPage(expectedUrl, action.stateManager))
473
+ return false;
474
+ tag('success').log('Navigation resolved after delayed redirect');
475
+ return true;
476
+ }
477
+ async askUserToResolve(action, message, expectedUrl, stopReason) {
478
+ let stopLine = '';
479
+ if (stopReason)
480
+ stopLine = `Navigator stopped: ${stopReason}\n`;
481
+ 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):`);
482
+ if (!userInput?.trim())
483
+ return false;
484
+ const resolved = await action.attempt(userInput, message);
485
+ if (!resolved)
486
+ return false;
487
+ if (!expectedUrl)
488
+ return true;
489
+ await action.getActor().wait(1);
490
+ return this.isOnExpectedPage(expectedUrl, action.stateManager);
491
+ }
444
492
  buildExperienceTools() {
445
493
  if (!this.experienceTracker)
446
494
  return undefined;
@@ -40,10 +40,7 @@ export declare class Pilot implements Agent {
40
40
  planTest(task: Test, currentState: ActionResult): Promise<string>;
41
41
  reviewNewPage(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
42
42
  analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
43
- settleExpectations(task: Test): Promise<Array<{
44
- text: string;
45
- status: 'passed' | 'failed' | 'unverified';
46
- }>>;
43
+ settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
47
44
  formatExpectations(task: Test): string;
48
45
  sendToPilot(userText: string, functionId: string, opts: {
49
46
  tools?: boolean;
@@ -90,3 +87,9 @@ export declare class Pilot implements Agent {
90
87
  buildDeletionScope(task: Test): string;
91
88
  getSystemPrompt(task: Test, initialState: ActionResult): string;
92
89
  }
90
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
91
+ export interface SettledExpectation {
92
+ text: string;
93
+ status: SettledStatus;
94
+ evidence?: string;
95
+ }