explorbot 0.5.3 → 0.5.4

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 (46) hide show
  1. package/boat/doc-collector/src/config.ts +1 -0
  2. package/boat/doc-collector/src/docbot.ts +10 -1
  3. package/boat/prima/src/envelope.ts +12 -6
  4. package/boat/prima/src/help.ts +4 -2
  5. package/boat/prima/src/prima.ts +37 -4
  6. package/boat/prima/src/session-log.ts +3 -3
  7. package/dist/boat/doc-collector/src/docbot.js +11 -1
  8. package/dist/boat/prima/src/envelope.js +12 -3
  9. package/dist/boat/prima/src/help.js +4 -2
  10. package/dist/boat/prima/src/prima.js +37 -4
  11. package/dist/boat/prima/src/session-log.js +3 -3
  12. package/dist/package.json +1 -1
  13. package/dist/rules/navigator/output.md +3 -3
  14. package/dist/src/action.js +4 -0
  15. package/dist/src/ai/driller.js +2 -28
  16. package/dist/src/ai/navigator.d.ts +5 -2
  17. package/dist/src/ai/navigator.js +15 -10
  18. package/dist/src/ai/pilot.d.ts +2 -7
  19. package/dist/src/ai/pilot.js +34 -7
  20. package/dist/src/ai/researcher.js +10 -8
  21. package/dist/src/ai/tools.js +6 -1
  22. package/dist/src/explorbot.js +1 -1
  23. package/dist/src/test-plan.d.ts +11 -0
  24. package/dist/src/test-plan.js +21 -0
  25. package/dist/src/utils/code-extractor.d.ts +1 -0
  26. package/dist/src/utils/code-extractor.js +40 -0
  27. package/dist/src/utils/html.d.ts +2 -0
  28. package/dist/src/utils/html.js +36 -1
  29. package/dist/src/utils/mdq/cli.js +3 -1
  30. package/dist/src/utils/web-sandbox.js +4 -4
  31. package/docs/doc-collection/basics.md +13 -0
  32. package/docs/web-testing/researcher.md +8 -0
  33. package/package.json +1 -1
  34. package/rules/navigator/output.md +3 -3
  35. package/src/action.ts +4 -0
  36. package/src/ai/driller.ts +2 -33
  37. package/src/ai/navigator.ts +15 -11
  38. package/src/ai/pilot.ts +35 -15
  39. package/src/ai/researcher.ts +10 -9
  40. package/src/ai/tools.ts +5 -1
  41. package/src/explorbot.ts +1 -1
  42. package/src/test-plan.ts +23 -0
  43. package/src/utils/code-extractor.ts +41 -0
  44. package/src/utils/html.ts +33 -1
  45. package/src/utils/mdq/cli.ts +3 -2
  46. package/src/utils/web-sandbox.ts +4 -4
@@ -171,6 +171,7 @@ interface DocbotConfig {
171
171
  minCanActions?: number;
172
172
  minInteractiveElements?: number;
173
173
  interactive?: boolean;
174
+ research?: 'light' | 'full';
174
175
  };
175
176
  }
176
177
 
@@ -118,9 +118,11 @@ class DocBot {
118
118
  }
119
119
  }
120
120
 
121
+ const light = this.useLightResearch();
121
122
  const research = await this.explorBot.agentResearcher().research(state, {
122
- screenshot: this.shouldUseScreenshots(),
123
+ screenshot: !light && this.shouldUseScreenshots(),
123
124
  force: true,
125
+ light,
124
126
  });
125
127
  const pagePath = this.getPageFilePath(state.url);
126
128
  const captureState = this.shouldUseScreenshots()
@@ -221,6 +223,13 @@ class DocBot {
221
223
  return true;
222
224
  }
223
225
 
226
+ private useLightResearch(): boolean {
227
+ const research = this.config.docs?.research;
228
+ if (research === 'light') return true;
229
+ if (research === 'full') return false;
230
+ return this.config.docs?.interactive !== true;
231
+ }
232
+
224
233
  private shouldCollapseTemplates(override?: boolean): boolean {
225
234
  if (override === false) {
226
235
  return false;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import type { SettledExpectation } from '../../../src/test-plan.ts';
3
4
 
4
5
  export const STATUS_FILE = 'status.json';
5
6
 
@@ -26,16 +27,16 @@ export interface EnvelopeData {
26
27
  ok: boolean;
27
28
  command: string;
28
29
  used?: string[];
29
- page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
30
+ page: { url: string; previousUrl?: string; redirectedFrom?: string; title: string; state: string; visits: number };
30
31
  changes?: string | null;
31
32
  steps?: Array<{ label: string; ok: boolean; unconfirmed?: boolean; proof: string }>;
32
- expectations?: Array<{ text: string; status: 'passed' | 'failed' | 'unverified' | 'contradiction'; evidence?: string }>;
33
+ expectations?: SettledExpectation[];
33
34
  warning?: string;
34
35
  stepFiles?: string;
35
36
  value?: string;
36
37
  answer?: string;
37
38
  research?: string;
38
- assertions?: Array<{ code: string; passed: boolean; proof: string[] }>;
39
+ assertions?: Array<{ code: string; passed: boolean; proof: string[]; error?: string }>;
39
40
  failure?: { error: string; compactAria?: string };
40
41
  instance: InstanceInfo;
41
42
  status?: string;
@@ -88,12 +89,13 @@ function renderResult(data: EnvelopeData): string {
88
89
  }
89
90
 
90
91
  function renderPage(data: EnvelopeData): string {
91
- const { url, previousUrl, title, state, visits } = data.page;
92
+ const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
92
93
  const urlLabel = `url: ${url}`;
93
94
  const stateLabel = `state: ${state}`;
94
95
  const width = Math.max(urlLabel.length, stateLabel.length) + 3;
95
96
  let changedMarker = '';
96
97
  if (previousUrl && previousUrl !== url) changedMarker = `(changed: ${previousUrl} → ${url})`;
98
+ if (redirectedFrom) changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
97
99
  const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
98
100
  return section('Page', lines.join('\n'));
99
101
  }
@@ -129,7 +131,8 @@ function renderExpectations(data: EnvelopeData): string | null {
129
131
  const lines: string[] = [];
130
132
  data.expectations.forEach((expectation, index) => {
131
133
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
132
- if (expectation.status !== 'contradiction' && expectation.status !== 'failed') return;
134
+ if (expectation.resolution) lines.push(` resolved: ${expectation.resolution}`);
135
+ if (!expectation.contradicted && !expectation.failed && !expectation.resolution) return;
133
136
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
134
137
  });
135
138
  return section('Expected outcomes', lines.join('\n'));
@@ -153,7 +156,10 @@ function renderOutcome(data: EnvelopeData): string | null {
153
156
  .map((line) => line.trim())
154
157
  .filter((line) => line && !line.startsWith('//'))
155
158
  .join(' ');
156
- return `${code} => ${assertion.passed ? 'PASSED' : 'FAILED'}`;
159
+ if (assertion.passed) return `${code} => PASSED`;
160
+ const reason = assertion.error?.split('\n').find(Boolean);
161
+ if (!reason) return `${code} => FAILED`;
162
+ return `${code} => FAILED\n ${reason}`;
157
163
  });
158
164
 
159
165
  const proof = data.assertions.flatMap((assertion) => assertion.proof);
@@ -27,8 +27,10 @@ export const checkHelp = dedent`
27
27
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
28
28
  "not verified" means never checked, not false.
29
29
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
30
- CONTRADICTION means screenshot and log disagree; judge the html, aria and
31
- screenshot under ### Artifacts yourself.
30
+ When screenshot and log disagree, the page structure breaks the tie: the outcome
31
+ becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
32
+ CONTRADICTION remains when it cannot be broken, or when the page holds something
33
+ it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
32
34
  ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
33
35
  reported as such rather than as an app failure.
34
36
  Side issues found on the way go under ### Answer, not as step failures.
@@ -239,6 +239,10 @@ export class Prima {
239
239
 
240
240
  if (aiError) return this.failureEnvelope(command, aiError, previousState);
241
241
 
242
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
243
+ await this.judgeUnreportedInstructions(ledger, trace);
244
+ }
245
+
242
246
  if (trace.length && ledger.some((entry) => entry.status === 'open')) {
243
247
  await this.settleLedger(conversation, provider, ledger, trace);
244
248
  }
@@ -310,6 +314,32 @@ export class Prima {
310
314
  return true;
311
315
  }
312
316
 
317
+ private async judgeUnreportedInstructions(ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
318
+ const judge = this.bot.judge?.();
319
+ if (!judge) return;
320
+
321
+ const current = this.bot.stateManager().getCurrentState();
322
+ let page = '';
323
+ if (current) page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
324
+ const actions = trace
325
+ .map((step) => {
326
+ if (step.ok) return `ok ${step.label}`;
327
+ return `FAIL ${step.label}`;
328
+ })
329
+ .join('\n');
330
+ const state = { actions, finalUrl: current?.url || '', finalPage: page };
331
+
332
+ const open = ledger.filter((entry) => entry.status === 'open');
333
+ const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
334
+
335
+ open.forEach((entry, index) => {
336
+ if (decisions[index].rejected) return;
337
+ entry.status = 'done';
338
+ entry.proof = 'judged from the actions that ran and the final page';
339
+ trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
340
+ });
341
+ }
342
+
313
343
  private async settleLedger(conversation: any, provider: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
314
344
  conversation.addUserText(dedent`
315
345
  The run is over and these instructions were never reported:
@@ -376,8 +406,8 @@ export class Prima {
376
406
  envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
377
407
  }
378
408
 
379
- const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
380
- const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
409
+ const unreached = envelope.expectations.filter((expectation) => expectation.failed);
410
+ const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
381
411
  envelope.ok = !unreached.length && !contradicted.length;
382
412
 
383
413
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
@@ -455,9 +485,10 @@ export class Prima {
455
485
  }
456
486
 
457
487
  let navigationError: unknown = null;
488
+ let arrived = true;
458
489
 
459
490
  try {
460
- await this.bot.agentNavigator().visit(target);
491
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
461
492
  } catch (error) {
462
493
  navigationError = error;
463
494
  }
@@ -467,7 +498,9 @@ export class Prima {
467
498
  const used: string[] = [];
468
499
  if (isUrl) used.push(code);
469
500
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
470
- return this.successEnvelope(command, used, result, previousState);
501
+ const envelope = await this.successEnvelope(command, used, result, previousState);
502
+ if (!arrived) envelope.page.redirectedFrom = target;
503
+ return envelope;
471
504
  }
472
505
 
473
506
  async browserStart(): Promise<void> {
@@ -4,7 +4,7 @@ import { outputPath } from '../../../src/config.ts';
4
4
  import { safeFilename } from '../../../src/utils/strings.ts';
5
5
  import { type EnvelopeData, renderEnvelope } from './envelope.ts';
6
6
 
7
- const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
7
+ const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
8
8
 
9
9
  export function sessionsDir(): string {
10
10
  return outputPath('prima', 'sessions');
@@ -74,7 +74,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
74
74
 
75
75
  for (const expectation of envelope.expectations || []) {
76
76
  const entry: any = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
77
- if (expectation.status === 'failed') entry.error = 'the run did not reach this outcome';
77
+ if (expectation.failed) entry.error = 'the run did not reach this outcome';
78
78
  steps.push(entry);
79
79
  }
80
80
 
@@ -82,7 +82,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
82
82
  const entry: any = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
83
83
  if (!assertion.passed) {
84
84
  entry.status = 'failed';
85
- entry.error = 'the assertion did not hold on the page';
85
+ entry.error = assertion.error || 'the assertion did not hold on the page';
86
86
  }
87
87
  steps.push(entry);
88
88
  }
@@ -103,9 +103,11 @@ class DocBot {
103
103
  continue;
104
104
  }
105
105
  }
106
+ const light = this.useLightResearch();
106
107
  const research = await this.explorBot.agentResearcher().research(state, {
107
- screenshot: this.shouldUseScreenshots(),
108
+ screenshot: !light && this.shouldUseScreenshots(),
108
109
  force: true,
110
+ light,
109
111
  });
110
112
  const pagePath = this.getPageFilePath(state.url);
111
113
  const captureState = this.shouldUseScreenshots()
@@ -197,6 +199,14 @@ class DocBot {
197
199
  }
198
200
  return true;
199
201
  }
202
+ useLightResearch() {
203
+ const research = this.config.docs?.research;
204
+ if (research === 'light')
205
+ return true;
206
+ if (research === 'full')
207
+ return false;
208
+ return this.config.docs?.interactive !== true;
209
+ }
200
210
  shouldCollapseTemplates(override) {
201
211
  if (override === false) {
202
212
  return false;
@@ -52,13 +52,15 @@ function renderResult(data) {
52
52
  return section('Result', lines.join('\n'));
53
53
  }
54
54
  function renderPage(data) {
55
- const { url, previousUrl, title, state, visits } = data.page;
55
+ const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
56
56
  const urlLabel = `url: ${url}`;
57
57
  const stateLabel = `state: ${state}`;
58
58
  const width = Math.max(urlLabel.length, stateLabel.length) + 3;
59
59
  let changedMarker = '';
60
60
  if (previousUrl && previousUrl !== url)
61
61
  changedMarker = `(changed: ${previousUrl} → ${url})`;
62
+ if (redirectedFrom)
63
+ changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
62
64
  const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
63
65
  return section('Page', lines.join('\n'));
64
66
  }
@@ -96,7 +98,9 @@ function renderExpectations(data) {
96
98
  const lines = [];
97
99
  data.expectations.forEach((expectation, index) => {
98
100
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
99
- if (expectation.status !== 'contradiction' && expectation.status !== 'failed')
101
+ if (expectation.resolution)
102
+ lines.push(` resolved: ${expectation.resolution}`);
103
+ if (!expectation.contradicted && !expectation.failed && !expectation.resolution)
100
104
  return;
101
105
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean))
102
106
  lines.push(` ${line}`);
@@ -123,7 +127,12 @@ function renderOutcome(data) {
123
127
  .map((line) => line.trim())
124
128
  .filter((line) => line && !line.startsWith('//'))
125
129
  .join(' ');
126
- return `${code} => ${assertion.passed ? 'PASSED' : 'FAILED'}`;
130
+ if (assertion.passed)
131
+ return `${code} => PASSED`;
132
+ const reason = assertion.error?.split('\n').find(Boolean);
133
+ if (!reason)
134
+ return `${code} => FAILED`;
135
+ return `${code} => FAILED\n ${reason}`;
127
136
  });
128
137
  const proof = data.assertions.flatMap((assertion) => assertion.proof);
129
138
  if (proof.length)
@@ -25,8 +25,10 @@ export const checkHelp = dedent `
25
25
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
26
26
  "not verified" means never checked, not false.
27
27
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
28
- CONTRADICTION means screenshot and log disagree; judge the html, aria and
29
- screenshot under ### Artifacts yourself.
28
+ When screenshot and log disagree, the page structure breaks the tie: the outcome
29
+ becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
30
+ CONTRADICTION remains when it cannot be broken, or when the page holds something
31
+ it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
30
32
  ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
31
33
  reported as such rather than as an app failure.
32
34
  Side issues found on the way go under ### Answer, not as step failures.
@@ -220,6 +220,9 @@ export class Prima {
220
220
  }
221
221
  if (aiError)
222
222
  return this.failureEnvelope(command, aiError, previousState);
223
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
224
+ await this.judgeUnreportedInstructions(ledger, trace);
225
+ }
223
226
  if (trace.length && ledger.some((entry) => entry.status === 'open')) {
224
227
  await this.settleLedger(conversation, provider, ledger, trace);
225
228
  }
@@ -283,6 +286,32 @@ export class Prima {
283
286
  }
284
287
  return true;
285
288
  }
289
+ async judgeUnreportedInstructions(ledger, trace) {
290
+ const judge = this.bot.judge?.();
291
+ if (!judge)
292
+ return;
293
+ const current = this.bot.stateManager().getCurrentState();
294
+ let page = '';
295
+ if (current)
296
+ page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
297
+ const actions = trace
298
+ .map((step) => {
299
+ if (step.ok)
300
+ return `ok ${step.label}`;
301
+ return `FAIL ${step.label}`;
302
+ })
303
+ .join('\n');
304
+ const state = { actions, finalUrl: current?.url || '', finalPage: page };
305
+ const open = ledger.filter((entry) => entry.status === 'open');
306
+ const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
307
+ open.forEach((entry, index) => {
308
+ if (decisions[index].rejected)
309
+ return;
310
+ entry.status = 'done';
311
+ entry.proof = 'judged from the actions that ran and the final page';
312
+ trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
313
+ });
314
+ }
286
315
  async settleLedger(conversation, provider, ledger, trace) {
287
316
  conversation.addUserText(dedent `
288
317
  The run is over and these instructions were never reported:
@@ -343,8 +372,8 @@ export class Prima {
343
372
  if (!result.screenshot || !this.visionEnabled()) {
344
373
  envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
345
374
  }
346
- const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
347
- const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
375
+ const unreached = envelope.expectations.filter((expectation) => expectation.failed);
376
+ const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
348
377
  envelope.ok = !unreached.length && !contradicted.length;
349
378
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
350
379
  if (problems.length)
@@ -413,8 +442,9 @@ export class Prima {
413
442
  return judged;
414
443
  }
415
444
  let navigationError = null;
445
+ let arrived = true;
416
446
  try {
417
- await this.bot.agentNavigator().visit(target);
447
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
418
448
  }
419
449
  catch (error) {
420
450
  navigationError = error;
@@ -425,7 +455,10 @@ export class Prima {
425
455
  if (isUrl)
426
456
  used.push(code);
427
457
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
428
- return this.successEnvelope(command, used, result, previousState);
458
+ const envelope = await this.successEnvelope(command, used, result, previousState);
459
+ if (!arrived)
460
+ envelope.page.redirectedFrom = target;
461
+ return envelope;
429
462
  }
430
463
  async browserStart() {
431
464
  const config = await this.loadConfig();
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { outputPath } from "../../../src/config.js";
4
4
  import { safeFilename } from "../../../src/utils/strings.js";
5
5
  import { renderEnvelope } from "./envelope.js";
6
- const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
6
+ const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
7
7
  export function sessionsDir() {
8
8
  return outputPath('prima', 'sessions');
9
9
  }
@@ -64,7 +64,7 @@ function commandSteps(envelope) {
64
64
  }
65
65
  for (const expectation of envelope.expectations || []) {
66
66
  const entry = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
67
- if (expectation.status === 'failed')
67
+ if (expectation.failed)
68
68
  entry.error = 'the run did not reach this outcome';
69
69
  steps.push(entry);
70
70
  }
@@ -72,7 +72,7 @@ function commandSteps(envelope) {
72
72
  const entry = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
73
73
  if (!assertion.passed) {
74
74
  entry.status = 'failed';
75
- entry.error = 'the assertion did not hold on the page';
75
+ entry.error = assertion.error || 'the assertion did not hold on the page';
76
76
  }
77
77
  steps.push(entry);
78
78
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  <rules>
2
- Do not invent locators, focus only on locators from HTML PAGE.
2
+ Do not invent locators, focus only on locators from the <page> context.
3
3
  Provide up to {{maxAttempts}} various code suggestions to achieve the result.
4
4
  If there was already successful solution in <experience> use it as a first solution.
5
5
 
6
6
  If no successful solution was found in <experience> propose codeblocks for each area that can help to achieve the result.
7
7
  Do not stick only to the first found element as it might be hidden or not availble on the page.
8
- If you think HTML contains several areas that can help to achieve the result, propose codeblocks for each such area.
8
+ If you think the page contains several areas that can help to achieve the result, propose codeblocks for each such area.
9
9
  Use exact locators that can pick the elements from each areas.
10
10
  Detect such duplicated areas by looking for duplicate IDs, data-ids, forms, etc.
11
11
 
@@ -24,7 +24,7 @@ Your response must start explanation of what you are going to do to achive the r
24
24
  It is important to explain intention before proposing code.
25
25
  Response must also valid CodeceptJS code in code blocks.
26
26
  Propose codeblock from successful solutions in <experience> first if they exist.
27
- Use only locators from HTML PAGE that was passed in <page> context.
27
+ Use only locators from the <page> context.
28
28
  </output>
29
29
 
30
30
 
@@ -548,6 +548,10 @@ export const attachStepLogger = (target, assertionsTarget) => {
548
548
  if (batchFailed)
549
549
  return;
550
550
  const executed = { command: step.toCode(), success: !error };
551
+ if (Object.keys(step.opts || {}).length) {
552
+ const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
553
+ executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
554
+ }
551
555
  if (error) {
552
556
  executed.error = errorToString(error);
553
557
  batchFailed = true;
@@ -6,6 +6,7 @@ import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Test, TestResult } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
+ import { splitTopLevel } from "../utils/code-extractor.js";
9
10
  import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from "../utils/html.js";
10
11
  import { createDebug, tag } from "../utils/logger.js";
11
12
  import { loop, pause } from "../utils/loop.js";
@@ -933,34 +934,7 @@ function countTopLevelArgCount(line) {
933
934
  const end = line.lastIndexOf(')');
934
935
  if (start === -1 || end === -1 || end <= start + 1)
935
936
  return 0;
936
- const body = line.slice(start + 1, end);
937
- let count = 1;
938
- let depth = 0;
939
- let quote = '';
940
- for (let i = 0; i < body.length; i++) {
941
- const char = body[i];
942
- const escaped = body[i - 1] === '\\';
943
- if (quote) {
944
- if (char === quote && !escaped)
945
- quote = '';
946
- continue;
947
- }
948
- if (char === '"' || char === "'" || char === '`') {
949
- quote = char;
950
- continue;
951
- }
952
- if (char === '(' || char === '[' || char === '{') {
953
- depth++;
954
- continue;
955
- }
956
- if (char === ')' || char === ']' || char === '}') {
957
- depth = Math.max(0, depth - 1);
958
- continue;
959
- }
960
- if (char === ',' && depth === 0)
961
- count++;
962
- }
963
- return count;
937
+ return splitTopLevel(line.slice(start + 1, end), ',').length;
964
938
  }
965
939
  function buildClassSelector(tag, classes) {
966
940
  const safeClasses = classes.filter((cls) => /^[a-z0-9_-]+$/i.test(cls)).slice(0, 5);
@@ -35,8 +35,10 @@ declare class Navigator implements Agent {
35
35
  }, expectedUrl: string): string;
36
36
  isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean;
37
37
  isOnExpectedPage(expectedUrl: string, stateManager: any): boolean;
38
- visit(url: string): Promise<void>;
39
- visitOnce(url: string): Promise<void>;
38
+ visit(url: string, opts?: {
39
+ recover?: boolean;
40
+ }): Promise<boolean>;
41
+ visitOnce(url: string, recover: boolean): Promise<boolean>;
40
42
  navigationError(url: string, fallback: string): Error;
41
43
  failureReason(stopReason: string | null, knowledge: string, url: string): string | null;
42
44
  resolveState(message: string, actionResult: ActionResult, opts?: {
@@ -98,5 +100,6 @@ export type AssertionResult = {
98
100
  code: string;
99
101
  passed: boolean;
100
102
  proof: string[];
103
+ error?: string;
101
104
  };
102
105
  export { Navigator };
@@ -119,10 +119,10 @@ class Navigator {
119
119
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
120
120
  return matchesNavigationUrl(expectedUrl, currentUrl);
121
121
  }
122
- async visit(url) {
123
- return this.visitOnce(url);
122
+ async visit(url, opts = {}) {
123
+ return this.visitOnce(url, opts.recover !== false);
124
124
  }
125
- async visitOnce(url) {
125
+ async visitOnce(url, recover) {
126
126
  try {
127
127
  const action = this.explorer.action();
128
128
  await action.execute(`I.amOnPage('${url}')`);
@@ -131,7 +131,10 @@ class Navigator {
131
131
  throw new Error(`Navigation to "${url}" opened an empty page. The target must be a valid URL path starting with "/".`);
132
132
  }
133
133
  await this.hooksRunner.runBeforeHook('navigator', url);
134
- if (!this.isOnExpectedPage(url, action.stateManager)) {
134
+ if (!recover && action.lastError)
135
+ throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
136
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
137
+ if (recover && !arrived) {
135
138
  const actualPath = action.stateManager.getCurrentState()?.url || '';
136
139
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
137
140
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -154,6 +157,7 @@ class Navigator {
154
157
  }
155
158
  await this.explorer.capture({ screenshot: true });
156
159
  await this.hooksRunner.runAfterHook('navigator', url);
160
+ return arrived || recover;
157
161
  }
158
162
  catch (error) {
159
163
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -252,7 +256,7 @@ class Navigator {
252
256
  return;
253
257
  }
254
258
  tag('operation').log('Feeding failures back to AI for a new batch...');
255
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
259
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
256
260
  htmlContextAdded = true;
257
261
  codeBlocks = [];
258
262
  batchFailures.length = 0;
@@ -347,10 +351,6 @@ class Navigator {
347
351
 
348
352
  <page>
349
353
  ${actionResult.toAiContext()}
350
-
351
- <page_html>
352
- ${await actionResult.combinedHtml()}
353
- </page_html>
354
354
  </page>
355
355
 
356
356
  <task>
@@ -360,6 +360,8 @@ class Navigator {
360
360
  Propose different solutions to achieve the result.
361
361
  Solution should be valid CodeceptJS code.
362
362
  Use only data from the <page> context to plan the solution.
363
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
364
+ Full HTML is provided if these solutions fail.
363
365
  Try various ways to achieve the result
364
366
  </task>
365
367
 
@@ -722,7 +724,10 @@ class Navigator {
722
724
  await action.exitIframe();
723
725
  const verified = await action.attempt(codeBlock, message);
724
726
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
725
- results.push({ code: codeBlock, passed: verified, proof });
727
+ const result = { code: codeBlock, passed: verified, proof };
728
+ if (!verified && action.lastError)
729
+ result.error = action.lastError.message;
730
+ results.push(result);
726
731
  if (verified) {
727
732
  tag('success').log('Verification passed');
728
733
  successfulCodes.push(codeBlock);
@@ -3,7 +3,7 @@ import type { RequestStore } from '../api/request-store.js';
3
3
  import type Explorer from '../explorer.js';
4
4
  import type { PlaywrightRecorder } from '../playwright-recorder.js';
5
5
  import type { StateManager } from '../state-manager.js';
6
- import { type Test } from '../test-plan.js';
6
+ import { SettledExpectation, type SettledStatus, type Test } from '../test-plan.js';
7
7
  import type { Agent, AgentDeps } from './agent.js';
8
8
  import type { Conversation } from './conversation.js';
9
9
  import type { Fisherman } from './fisherman.js';
@@ -44,6 +44,7 @@ export declare class Pilot implements Agent {
44
44
  periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
45
45
  analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
46
46
  settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
47
+ resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]>;
47
48
  settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
48
49
  formatExpectations(task: Test): string;
49
50
  sendToPilot(userText: string, functionId: string, opts: {
@@ -104,9 +105,3 @@ export declare class Pilot implements Agent {
104
105
  formatActions(toolCalls: any[]): string;
105
106
  getSystemPrompt(task: Test, initialState: ActionResult): string;
106
107
  }
107
- export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
108
- export interface SettledExpectation {
109
- text: string;
110
- status: SettledStatus;
111
- evidence?: string;
112
- }