explorbot 0.5.3 → 0.5.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 (60) 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/README.md +5 -10
  4. package/boat/prima/package.json +1 -1
  5. package/boat/prima/src/cli.ts +8 -50
  6. package/boat/prima/src/envelope.ts +12 -6
  7. package/boat/prima/src/help.ts +4 -2
  8. package/boat/prima/src/prima.ts +58 -81
  9. package/boat/prima/src/session-log.ts +3 -3
  10. package/dist/boat/doc-collector/src/docbot.js +11 -1
  11. package/dist/boat/prima/src/cli.js +9 -45
  12. package/dist/boat/prima/src/envelope.js +12 -3
  13. package/dist/boat/prima/src/help.js +4 -2
  14. package/dist/boat/prima/src/prima.js +58 -77
  15. package/dist/boat/prima/src/session-log.js +3 -3
  16. package/dist/package.json +1 -1
  17. package/dist/rules/navigator/output.md +3 -3
  18. package/dist/src/action.js +4 -0
  19. package/dist/src/ai/driller.js +2 -28
  20. package/dist/src/ai/navigator.d.ts +5 -2
  21. package/dist/src/ai/navigator.js +23 -12
  22. package/dist/src/ai/pilot.d.ts +2 -7
  23. package/dist/src/ai/pilot.js +34 -7
  24. package/dist/src/ai/researcher.js +10 -8
  25. package/dist/src/ai/rules.d.ts +1 -0
  26. package/dist/src/ai/rules.js +7 -0
  27. package/dist/src/ai/tools.js +6 -1
  28. package/dist/src/explorbot.js +1 -1
  29. package/dist/src/test-plan.d.ts +11 -0
  30. package/dist/src/test-plan.js +21 -0
  31. package/dist/src/utils/code-extractor.d.ts +1 -0
  32. package/dist/src/utils/code-extractor.js +40 -0
  33. package/dist/src/utils/error-page.d.ts +1 -0
  34. package/dist/src/utils/error-page.js +8 -0
  35. package/dist/src/utils/html.d.ts +2 -0
  36. package/dist/src/utils/html.js +36 -1
  37. package/dist/src/utils/mdq/cli.js +3 -1
  38. package/dist/src/utils/mdq/query.d.ts +4 -1
  39. package/dist/src/utils/mdq/query.js +48 -7
  40. package/dist/src/utils/web-sandbox.js +4 -4
  41. package/docs/doc-collection/basics.md +13 -0
  42. package/docs/reference/commands.md +4 -6
  43. package/docs/web-testing/researcher.md +8 -0
  44. package/package.json +1 -1
  45. package/rules/navigator/output.md +3 -3
  46. package/src/action.ts +4 -0
  47. package/src/ai/driller.ts +2 -33
  48. package/src/ai/navigator.ts +23 -13
  49. package/src/ai/pilot.ts +35 -15
  50. package/src/ai/researcher.ts +10 -9
  51. package/src/ai/rules.ts +8 -0
  52. package/src/ai/tools.ts +5 -1
  53. package/src/explorbot.ts +1 -1
  54. package/src/test-plan.ts +23 -0
  55. package/src/utils/code-extractor.ts +41 -0
  56. package/src/utils/error-page.ts +8 -0
  57. package/src/utils/html.ts +33 -1
  58. package/src/utils/mdq/cli.ts +3 -2
  59. package/src/utils/mdq/query.ts +48 -7
  60. package/src/utils/web-sandbox.ts +4 -4
@@ -11,7 +11,7 @@ import { JUDGE_PAGE_CAP, UNDECIDED } from "../../../src/ai/judge.js";
11
11
  import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
12
12
  import { actionRule, locatorRule } from "../../../src/ai/rules.js";
13
13
  import { createAgentTools, createCodeceptJSTools, createRefTools } from "../../../src/ai/tools.js";
14
- import { getAliveEndpoint, launchServer, listInstances, stopServer } from "../../../src/browser-server.js";
14
+ import { getAliveEndpoint, listInstances } from "../../../src/browser-server.js";
15
15
  import { ConfigCommand } from "../../../src/commands/config-command.js";
16
16
  import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, outputPath } from "../../../src/config.js";
17
17
  import { ExplorBot } from "../../../src/explorbot.js";
@@ -67,7 +67,6 @@ export class Prima {
67
67
  artifactsDir;
68
68
  hash;
69
69
  sessionUrl;
70
- server = null;
71
70
  attached = null;
72
71
  session = null;
73
72
  constructor(options = {}) {
@@ -220,6 +219,9 @@ export class Prima {
220
219
  }
221
220
  if (aiError)
222
221
  return this.failureEnvelope(command, aiError, previousState);
222
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
223
+ await this.judgeUnreportedInstructions(ledger, trace);
224
+ }
223
225
  if (trace.length && ledger.some((entry) => entry.status === 'open')) {
224
226
  await this.settleLedger(conversation, provider, ledger, trace);
225
227
  }
@@ -283,6 +285,32 @@ export class Prima {
283
285
  }
284
286
  return true;
285
287
  }
288
+ async judgeUnreportedInstructions(ledger, trace) {
289
+ const judge = this.bot.judge?.();
290
+ if (!judge)
291
+ return;
292
+ const current = this.bot.stateManager().getCurrentState();
293
+ let page = '';
294
+ if (current)
295
+ page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
296
+ const actions = trace
297
+ .map((step) => {
298
+ if (step.ok)
299
+ return `ok ${step.label}`;
300
+ return `FAIL ${step.label}`;
301
+ })
302
+ .join('\n');
303
+ const state = { actions, finalUrl: current?.url || '', finalPage: page };
304
+ const open = ledger.filter((entry) => entry.status === 'open');
305
+ 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)));
306
+ open.forEach((entry, index) => {
307
+ if (decisions[index].rejected)
308
+ return;
309
+ entry.status = 'done';
310
+ entry.proof = 'judged from the actions that ran and the final page';
311
+ trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
312
+ });
313
+ }
286
314
  async settleLedger(conversation, provider, ledger, trace) {
287
315
  conversation.addUserText(dedent `
288
316
  The run is over and these instructions were never reported:
@@ -343,8 +371,8 @@ export class Prima {
343
371
  if (!result.screenshot || !this.visionEnabled()) {
344
372
  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
373
  }
346
- const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
347
- const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
374
+ const unreached = envelope.expectations.filter((expectation) => expectation.failed);
375
+ const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
348
376
  envelope.ok = !unreached.length && !contradicted.length;
349
377
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
350
378
  if (problems.length)
@@ -413,8 +441,9 @@ export class Prima {
413
441
  return judged;
414
442
  }
415
443
  let navigationError = null;
444
+ let arrived = true;
416
445
  try {
417
- await this.bot.agentNavigator().visit(target);
446
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
418
447
  }
419
448
  catch (error) {
420
449
  navigationError = error;
@@ -425,29 +454,18 @@ export class Prima {
425
454
  if (isUrl)
426
455
  used.push(code);
427
456
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
428
- return this.successEnvelope(command, used, result, previousState);
457
+ const envelope = await this.successEnvelope(command, used, result, previousState);
458
+ if (!arrived)
459
+ envelope.page.redirectedFrom = target;
460
+ return envelope;
429
461
  }
430
- async browserStart() {
431
- const config = await this.loadConfig();
432
- let show = config.playwright.show || false;
433
- if (this.options.show)
434
- show = true;
435
- if (this.options.headless)
436
- show = false;
437
- this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
438
- }
439
- async browserStop(all = false) {
462
+ async browserCheck() {
440
463
  await this.loadConfig();
441
- if (this.attached)
442
- return false;
443
- if (!all)
444
- return this.stopInstance(this.instanceName());
445
- let stopped = false;
446
- for (const instance of listInstances()) {
447
- if (await this.stopInstance(instance.name))
448
- stopped = true;
449
- }
450
- return stopped;
464
+ const { match, candidates, browser } = await this.discover();
465
+ await browser?.close().catch(() => { });
466
+ if (!match)
467
+ throw this.missingSessionError(candidates);
468
+ return `browser: ${this.attachmentLabel(match)} at ${match.endpoint}`;
451
469
  }
452
470
  async config(json) {
453
471
  const [site] = listSites();
@@ -496,36 +514,6 @@ export class Prima {
496
514
  `upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
497
515
  ].join('\n');
498
516
  }
499
- async browserStatus() {
500
- await this.loadConfig();
501
- const info = await this.instanceInfo();
502
- const endpoint = await getAliveEndpoint(info.name);
503
- const others = info.others.map((other) => other.name).join(', ') || 'none';
504
- const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
505
- if (!endpoint)
506
- lines.push('browser: not running');
507
- if (endpoint)
508
- lines.push(`browser: running at ${endpoint}`);
509
- return lines.join('\n');
510
- }
511
- async browserList() {
512
- await this.loadConfig();
513
- const lines = [];
514
- for (const instance of listInstances()) {
515
- const endpoint = await getAliveEndpoint(instance.name);
516
- if (!endpoint)
517
- continue;
518
- lines.push(`prima --instance ${instance.name} ${endpoint}`);
519
- }
520
- const discovery = await this.discover();
521
- await discovery.browser?.close().catch(() => { });
522
- for (const descriptor of discovery.candidates) {
523
- lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
524
- }
525
- if (!lines.length)
526
- return 'no browser instances running';
527
- return lines.join('\n');
528
- }
529
517
  async instanceInfo() {
530
518
  const name = this.instanceName();
531
519
  const others = listInstances()
@@ -588,21 +576,25 @@ export class Prima {
588
576
  const { match, candidates, browser } = discovered || (await this.discover());
589
577
  if (match && (await this.attachToEndpoint(match, browser)))
590
578
  return;
591
- if (!match && candidates.length) {
579
+ if (!match && candidates.length)
580
+ throw this.missingSessionError(candidates);
581
+ if (await this.connectOwnInstance())
582
+ return;
583
+ throw this.missingSessionError(candidates);
584
+ }
585
+ missingSessionError(candidates) {
586
+ if (candidates.length) {
592
587
  const titles = candidates.map((candidate) => candidate.title).join(', ');
593
- throw new Error(dedent `
588
+ return new Error(dedent `
594
589
  Several playwright-cli sessions are open: ${titles}
595
590
  Pick one with --pw-session <title>.
596
591
  `);
597
592
  }
598
- if (await this.connectOwnInstance())
599
- return;
600
- throw new Error(dedent `
601
- No browser to drive for instance "${this.instanceName()}".
602
- Open one first:
603
- playwright-cli open <url> prima attaches to this workspace session by default
604
- prima browser start starts a prima-owned browser instead
605
- Prima never launches a browser implicitly.
593
+ return new Error(dedent `
594
+ No playwright-cli session is open for prima to drive.
595
+ Start one first:
596
+ playwright-cli open ${this.options.url || '<url>'}
597
+ Prima attaches to this workspace session by default and never launches a browser itself.
606
598
  `);
607
599
  }
608
600
  async discover(descriptors = readDescriptors()) {
@@ -670,17 +662,6 @@ export class Prima {
670
662
  this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
671
663
  return true;
672
664
  }
673
- async launchOwnServer(opts, instance) {
674
- return launchServer(opts, instance);
675
- }
676
- async stopInstance(instance) {
677
- if (instance === this.instanceName()) {
678
- const server = this.server;
679
- this.server = null;
680
- await server?.close();
681
- }
682
- return stopServer(instance);
683
- }
684
665
  isUrlTarget(target) {
685
666
  const value = target.trim();
686
667
  if (value.startsWith('/'))
@@ -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.5",
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 };
@@ -7,6 +7,7 @@ import { normalizeUrl } from '../state-manager.js';
7
7
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
8
  import { getCliName } from "../utils/cli-name.js";
9
9
  import { extractCodeBlocks } from '../utils/code-extractor.js';
10
+ import { isLoginPage } from "../utils/error-page.js";
10
11
  import { HooksRunner } from "../utils/hooks-runner.js";
11
12
  import { createDebug, pluralize, tag } from '../utils/logger.js';
12
13
  import { loop, pause } from '../utils/loop.js';
@@ -15,7 +16,7 @@ import { normalizeInlineText } from "../utils/strings.js";
15
16
  import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
16
17
  import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
17
18
  import { Researcher } from "./researcher.js";
18
- import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
19
+ import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from './rules.js';
19
20
  import { isInteractive } from './task-agent.js';
20
21
  import { createLearnExperienceTool } from "./tools.js";
21
22
  const debugLog = createDebug('explorbot:navigator');
@@ -46,7 +47,7 @@ class Navigator {
46
47
  NEVER use executeScript, executeAsyncScript, or any JS evaluation to change the URL, bypass redirects, or fake the page state.
47
48
  If the target URL redirects to an authentication/login page, DO NOT try to force the original URL. Instead:
48
49
  1. Look for credentials in the provided knowledge/hint context and perform a real login through the form.
49
- 2. If no credentials are available, ask the user for credentials or ask the user to log in manually.
50
+ 2. If no credentials are available, stop and name the credentials that are missing. Never guess them.
50
51
  A redirect to /login, /sign_in, /auth, or similar is a signal that authentication is required — treat it as such, never as an obstacle to bypass.
51
52
  </constraints>
52
53
  `;
@@ -119,10 +120,10 @@ class Navigator {
119
120
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
120
121
  return matchesNavigationUrl(expectedUrl, currentUrl);
121
122
  }
122
- async visit(url) {
123
- return this.visitOnce(url);
123
+ async visit(url, opts = {}) {
124
+ return this.visitOnce(url, opts.recover !== false);
124
125
  }
125
- async visitOnce(url) {
126
+ async visitOnce(url, recover) {
126
127
  try {
127
128
  const action = this.explorer.action();
128
129
  await action.execute(`I.amOnPage('${url}')`);
@@ -131,7 +132,10 @@ class Navigator {
131
132
  throw new Error(`Navigation to "${url}" opened an empty page. The target must be a valid URL path starting with "/".`);
132
133
  }
133
134
  await this.hooksRunner.runBeforeHook('navigator', url);
134
- if (!this.isOnExpectedPage(url, action.stateManager)) {
135
+ if (!recover && action.lastError)
136
+ throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
137
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
138
+ if (recover && !arrived) {
135
139
  const actualPath = action.stateManager.getCurrentState()?.url || '';
136
140
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
137
141
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -154,6 +158,7 @@ class Navigator {
154
158
  }
155
159
  await this.explorer.capture({ screenshot: true });
156
160
  await this.hooksRunner.runAfterHook('navigator', url);
161
+ return arrived || recover;
157
162
  }
158
163
  catch (error) {
159
164
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -252,7 +257,7 @@ class Navigator {
252
257
  return;
253
258
  }
254
259
  tag('operation').log('Feeding failures back to AI for a new batch...');
255
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
260
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
256
261
  htmlContextAdded = true;
257
262
  codeBlocks = [];
258
263
  batchFailures.length = 0;
@@ -340,6 +345,9 @@ class Navigator {
340
345
  if (!experience && !actionResult.isInsideIframe) {
341
346
  experience = this.experienceTracker.renderExperienceFor(actionResult);
342
347
  }
348
+ let credentials = '';
349
+ if (isLoginPage(actionResult))
350
+ credentials = credentialsRule;
343
351
  return dedent `
344
352
  <message>
345
353
  ${message}
@@ -347,10 +355,6 @@ class Navigator {
347
355
 
348
356
  <page>
349
357
  ${actionResult.toAiContext()}
350
-
351
- <page_html>
352
- ${await actionResult.combinedHtml()}
353
- </page_html>
354
358
  </page>
355
359
 
356
360
  <task>
@@ -360,6 +364,8 @@ class Navigator {
360
364
  Propose different solutions to achieve the result.
361
365
  Solution should be valid CodeceptJS code.
362
366
  Use only data from the <page> context to plan the solution.
367
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
368
+ Full HTML is provided if these solutions fail.
363
369
  Try various ways to achieve the result
364
370
  </task>
365
371
 
@@ -367,6 +373,8 @@ class Navigator {
367
373
 
368
374
  ${unexpectedPopupRule}
369
375
 
376
+ ${credentials}
377
+
370
378
  ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
371
379
 
372
380
  ${experience}
@@ -722,7 +730,10 @@ class Navigator {
722
730
  await action.exitIframe();
723
731
  const verified = await action.attempt(codeBlock, message);
724
732
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
725
- results.push({ code: codeBlock, passed: verified, proof });
733
+ const result = { code: codeBlock, passed: verified, proof };
734
+ if (!verified && action.lastError)
735
+ result.error = action.lastError.message;
736
+ results.push(result);
726
737
  if (verified) {
727
738
  tag('success').log('Verification passed');
728
739
  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
- }
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
6
  import { Stats } from "../stats.js";
7
- import { TestResult, TestStatus } from "../test-plan.js";
7
+ import { SettledExpectation, TestResult, TestStatus } from "../test-plan.js";
8
8
  import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
@@ -26,6 +26,11 @@ const OUTCOME_STATUS = {
26
26
  'The run shows this outcome happened.': 'passed',
27
27
  'The run shows this outcome did not happen.': 'failed',
28
28
  };
29
+ const CONTRADICTION_RESOLUTION = {
30
+ 'The page structure agrees with the screenshot, and the outcome happened.': { status: 'passed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
31
+ 'The page structure agrees with the screenshot, and the outcome did not happen.': { status: 'failed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
32
+ 'The page structure agrees with the run log: the page holds it, but the screenshot does not show it.': { status: 'contradiction', resolution: 'the page structure agrees with the run log: the page holds it but does not display it, a likely rendering bug' },
33
+ };
29
34
  export class Pilot {
30
35
  emoji = '🧭';
31
36
  provider;
@@ -544,7 +549,7 @@ export class Pilot {
544
549
  settledByJudge = await this.settleByJudge(task, undecided);
545
550
  undecided = undecided.filter((text) => !settledByJudge.has(text));
546
551
  if (!undecided.length)
547
- return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
552
+ return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
548
553
  const schema = z.object({
549
554
  outcomes: z.array(z.object({
550
555
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
@@ -607,17 +612,39 @@ export class Pilot {
607
612
  if (!response)
608
613
  response = await settle(userContent, this.provider.getAgenticModel('pilot'));
609
614
  const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
610
- return task.expected.map((text) => {
615
+ const settled = task.expected.map((text) => {
611
616
  const byJudge = settledByJudge.get(text);
612
617
  if (byJudge)
613
- return { text, status: byJudge };
618
+ return new SettledExpectation(text, byJudge);
614
619
  if (!undecided.includes(text))
615
- return { text, status: decided(text) };
620
+ return new SettledExpectation(text, decided(text));
616
621
  const outcome = judged.get(text);
617
622
  if (!outcome)
618
- return { text, status: 'unverified' };
619
- return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
623
+ return new SettledExpectation(text, 'unverified');
624
+ return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
620
625
  });
626
+ return this.resolveContradictions(settled, finalState);
627
+ }
628
+ async resolveContradictions(outcomes, finalState) {
629
+ const judge = this.judge;
630
+ if (!judge || !finalState)
631
+ return outcomes;
632
+ if (!outcomes.some((outcome) => outcome.contradicted))
633
+ return outcomes;
634
+ const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
635
+ return Promise.all(outcomes.map(async (outcome) => {
636
+ if (!outcome.contradicted)
637
+ return outcome;
638
+ const decision = await judge.decide(`The screenshot and the run log disagree about the expected outcome: ${outcome.text}. Which side does the page structure support?`, [...Object.keys(CONTRADICTION_RESOLUTION), UNDECIDED], {
639
+ expectation: outcome.text,
640
+ disagreement: outcome.evidence || '',
641
+ page,
642
+ });
643
+ const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
644
+ if (!resolved)
645
+ return outcome;
646
+ return outcome.resolve(resolved.status, resolved.resolution);
647
+ }));
621
648
  }
622
649
  async settleByJudge(task, expectations) {
623
650
  const settled = new Map();
@@ -77,7 +77,7 @@ export class Researcher extends ResearcherBase {
77
77
  `;
78
78
  }
79
79
  async research(state, opts = {}) {
80
- const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
80
+ const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
81
81
  const maxRetries = this.settings.retries ?? 2;
82
82
  let retriesLeft = opts._retriesLeft ?? maxRetries;
83
83
  this.actionResult = ActionResult.fromState(state);
@@ -103,6 +103,8 @@ export class Researcher extends ResearcherBase {
103
103
  return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
104
104
  const displayUrl = state.fullUrl || state.url;
105
105
  tag('info').log(`Researching ${displayUrl} to understand the context...`);
106
+ if (light)
107
+ tag('info').log('Light research: single pass, locator verification skipped');
106
108
  setActivity(`${this.emoji} Researching...`, 'action');
107
109
  await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
108
110
  await this.hooksRunner.runBeforeHook('researcher', state.url);
@@ -135,7 +137,7 @@ export class Researcher extends ResearcherBase {
135
137
  }
136
138
  }
137
139
  const isOnCurrentState = this.actionResult.getStateHash() === this.stateManager.getCurrentState()?.hash;
138
- this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
140
+ this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
139
141
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
140
142
  const prompt = await this.buildResearchPrompt();
141
143
  conversation.addUserText(prompt);
@@ -164,12 +166,12 @@ export class Researcher extends ResearcherBase {
164
166
  // Stage 2: Test containers + locators
165
167
  result.parseLocators();
166
168
  debugLog(`Extracted ${result.locators.length} locators from research`);
167
- if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
169
+ if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
168
170
  tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
169
171
  await new Promise((r) => setTimeout(r, 1000));
170
172
  return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 });
171
173
  }
172
- if (!interrupted()) {
174
+ if (!light && !interrupted()) {
173
175
  const brokenContainers = await this.resolveContainers(result);
174
176
  const containerCount = result.containers.length;
175
177
  if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
@@ -187,7 +189,7 @@ export class Researcher extends ResearcherBase {
187
189
  await this.testLocators(toTest);
188
190
  }
189
191
  // Stage 3: Fix broken sections via AI conversation continuation
190
- if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
192
+ if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
191
193
  await this.fixBrokenSections(result, activeConversation);
192
194
  }
193
195
  // Focused section: unified Playwright probe (HTML+CSS+visibility).
@@ -227,11 +229,11 @@ export class Researcher extends ResearcherBase {
227
229
  }
228
230
  }
229
231
  // Stage 5: Backfill broken elements
230
- if (!interrupted()) {
232
+ if (!light && !interrupted()) {
231
233
  await this.backfillCoordinates(result);
232
234
  await this.backfillBrokenLocators(result);
233
235
  }
234
- if (!interrupted()) {
236
+ if (!light && !interrupted()) {
235
237
  await this.detectPagination(result);
236
238
  }
237
239
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
@@ -258,7 +260,7 @@ export class Researcher extends ResearcherBase {
258
260
  }
259
261
  result.cleanup();
260
262
  let researchFile = null;
261
- if (stateHash) {
263
+ if (stateHash && !light) {
262
264
  researchFile = saveResearch(researchState, result.text, combinedHtml);
263
265
  }
264
266
  const summaryText = mdq(result.text).query('section2(/^summary/i)').query('paragraph[0]').text().trim();
@@ -3,6 +3,7 @@ export declare const locatorRule: string;
3
3
  export declare const drillLocatorRule: string;
4
4
  export declare const fileUploadRule: string;
5
5
  export declare const formRequirementsRule: string;
6
+ export declare const credentialsRule: string;
6
7
  export declare const protectionRule: string;
7
8
  export declare const dataProtectionRules: string;
8
9
  export declare const capabilityGroundingRule: string;