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
@@ -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();
@@ -91,7 +91,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
91
91
  const attempts = [];
92
92
  let ambiguityError = null;
93
93
  for (let i = 0; i < commands.length; i++) {
94
- const command = transformContainsCommand(commands[i]);
94
+ let command = transformContainsCommand(commands[i]);
95
95
  let success = await action.attempt(command, explanation);
96
96
  const attempt = { command, success };
97
97
  if (action.lastError)
@@ -104,9 +104,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
104
104
  const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
105
105
  if (index)
106
106
  success = await action.attemptExactElementIndex(command, index, explanation);
107
+ if (success)
108
+ command = action.executedSteps.map((step) => step.command).join('\n') || command;
107
109
  }
108
110
  }
109
111
  if (success) {
112
+ attempt.command = command;
113
+ attempt.success = true;
114
+ attempt.error = undefined;
110
115
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
111
116
  if (!hasObservablePageChange(toolResult)) {
112
117
  activeNote.commit(TestResult.FAILED);
@@ -125,7 +125,7 @@ export class ExplorBot {
125
125
  await this.visit(this.options.from || '/');
126
126
  }
127
127
  async visit(url) {
128
- return this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
128
+ await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
129
129
  }
130
130
  async openTab() {
131
131
  await this.explorer.openTab();
@@ -33,6 +33,17 @@ export declare class ActiveNote {
33
33
  getMessage(): string;
34
34
  getStartTime(): number;
35
35
  }
36
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
37
+ export declare class SettledExpectation {
38
+ readonly text: string;
39
+ readonly status: SettledStatus;
40
+ readonly evidence?: string;
41
+ readonly resolution?: string;
42
+ constructor(text: string, status: SettledStatus, evidence?: string, resolution?: string);
43
+ get failed(): boolean;
44
+ get contradicted(): boolean;
45
+ resolve(status: SettledStatus, resolution: string): SettledExpectation;
46
+ }
36
47
  export interface StepData {
37
48
  text: string;
38
49
  duration?: number;
@@ -41,6 +41,27 @@ export class ActiveNote {
41
41
  return this.startTime;
42
42
  }
43
43
  }
44
+ export class SettledExpectation {
45
+ text;
46
+ status;
47
+ evidence;
48
+ resolution;
49
+ constructor(text, status, evidence, resolution) {
50
+ this.text = text;
51
+ this.status = status;
52
+ this.evidence = evidence;
53
+ this.resolution = resolution;
54
+ }
55
+ get failed() {
56
+ return this.status === 'failed';
57
+ }
58
+ get contradicted() {
59
+ return this.status === 'contradiction';
60
+ }
61
+ resolve(status, resolution) {
62
+ return new SettledExpectation(this.text, status, this.evidence, resolution);
63
+ }
64
+ }
44
65
  export class Task {
45
66
  id;
46
67
  description;
@@ -1 +1,2 @@
1
1
  export declare function extractCodeBlocks(aiResponse: string): string[];
2
+ export declare function splitTopLevel(code: string, separator: string): string[];
@@ -1,6 +1,9 @@
1
1
  import { createDebug } from './logger.js';
2
2
  const debugLog = createDebug('explorbot:code-extractor');
3
3
  const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
4
+ const QUOTES = ["'", '"', '`'];
5
+ const OPENING_BRACKETS = ['(', '[', '{'];
6
+ const CLOSING_BRACKETS = [')', ']', '}'];
4
7
  export function extractCodeBlocks(aiResponse) {
5
8
  const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
6
9
  const codeBlocks = [];
@@ -22,3 +25,40 @@ export function extractCodeBlocks(aiResponse) {
22
25
  }
23
26
  return codeBlocks;
24
27
  }
28
+ export function splitTopLevel(code, separator) {
29
+ const parts = [];
30
+ let current = '';
31
+ let depth = 0;
32
+ let quote = '';
33
+ for (let i = 0; i < code.length; i++) {
34
+ const char = code[i];
35
+ if (quote) {
36
+ current += char;
37
+ if (char === '\\')
38
+ current += code[++i] || '';
39
+ if (char === quote)
40
+ quote = '';
41
+ continue;
42
+ }
43
+ if (char === '/' && code[i + 1] === '/') {
44
+ while (i + 1 < code.length && code[i + 1] !== '\n')
45
+ i++;
46
+ continue;
47
+ }
48
+ if (char === separator && depth <= 0) {
49
+ parts.push(current);
50
+ current = '';
51
+ depth = 0;
52
+ continue;
53
+ }
54
+ current += char;
55
+ if (QUOTES.includes(char))
56
+ quote = char;
57
+ if (OPENING_BRACKETS.includes(char))
58
+ depth++;
59
+ if (CLOSING_BRACKETS.includes(char))
60
+ depth--;
61
+ }
62
+ parts.push(current);
63
+ return parts;
64
+ }
@@ -5,6 +5,7 @@ export declare const EXPLORBOT_ATTRS: {
5
5
  readonly coveredBy: "data-explorbot-covered-by";
6
6
  readonly context: "data-explorbot-context";
7
7
  readonly eidx: "data-explorbot-eidx";
8
+ readonly hidden: "data-explorbot-hidden";
8
9
  readonly hit: "data-explorbot-hit";
9
10
  readonly variant: "data-explorbot-variant";
10
11
  };
@@ -40,6 +41,7 @@ export declare const ELEMENT_EXTRACTION_CONFIG: {
40
41
  readonly coveredBy: "data-explorbot-covered-by";
41
42
  readonly context: "data-explorbot-context";
42
43
  readonly eidx: "data-explorbot-eidx";
44
+ readonly hidden: "data-explorbot-hidden";
43
45
  readonly hit: "data-explorbot-hit";
44
46
  readonly variant: "data-explorbot-variant";
45
47
  };
@@ -70,6 +70,7 @@ export const EXPLORBOT_ATTRS = {
70
70
  coveredBy: 'data-explorbot-covered-by',
71
71
  context: 'data-explorbot-context',
72
72
  eidx: 'data-explorbot-eidx',
73
+ hidden: 'data-explorbot-hidden',
73
74
  hit: 'data-explorbot-hit',
74
75
  variant: 'data-explorbot-variant',
75
76
  };
@@ -159,6 +160,20 @@ export function captureHtmlForSnapshot() {
159
160
  .join(','));
160
161
  }
161
162
  }
163
+ const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
164
+ const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
165
+ for (let i = 0; i < liveElements.length; i++) {
166
+ const source = liveElements[i];
167
+ if (source.matches('input, textarea, select, option, optgroup'))
168
+ continue;
169
+ if (source.checkVisibility())
170
+ continue;
171
+ if (!source.parentElement?.checkVisibility())
172
+ continue;
173
+ if (getComputedStyle(source).display === 'contents')
174
+ continue;
175
+ clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
176
+ }
162
177
  return clone.outerHTML;
163
178
  }
164
179
  export function extractElementData(el, config) {
@@ -1347,7 +1362,27 @@ function getAttribute(element, name) {
1347
1362
  const attr = element.attrs.find((a) => a.name === name);
1348
1363
  return attr?.value;
1349
1364
  }
1365
+ function getImgAlt(element) {
1366
+ let alt = '';
1367
+ function processNode(node) {
1368
+ if (alt)
1369
+ return;
1370
+ if ('tagName' in node) {
1371
+ const el = node;
1372
+ if (el.tagName.toLowerCase() === 'img') {
1373
+ alt = getAttribute(el, 'alt') || '';
1374
+ return;
1375
+ }
1376
+ }
1377
+ if ('childNodes' in node)
1378
+ node.childNodes.forEach(processNode);
1379
+ }
1380
+ processNode(element);
1381
+ return alt;
1382
+ }
1350
1383
  function hasHiddenClass(element) {
1384
+ if (getAttribute(element, EXPLORBOT_ATTRS.hidden))
1385
+ return true;
1351
1386
  const classAttr = element.attrs.find((attr) => attr.name === 'class');
1352
1387
  if (!classAttr)
1353
1388
  return false;
@@ -1393,7 +1428,7 @@ export function extractLinks(html) {
1393
1428
  if (href) {
1394
1429
  const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
1395
1430
  if (!shouldSkip) {
1396
- const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
1431
+ const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
1397
1432
  const title = sanitizeLinkTitle(rawTitle);
1398
1433
  if (title && title.length <= 100) {
1399
1434
  const key = `${href}|${title}`;
@@ -5,7 +5,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
5
5
  export async function runMdq(argv, readStdin) {
6
6
  const program = new Command();
7
7
  program
8
- .name('mdq')
8
+ .name('mdq-cli')
9
9
  .description('query and edit markdown')
10
10
  .argument('[selector]', 'markdown selector')
11
11
  .argument('[file]', 'file to read; stdin when omitted')
@@ -29,6 +29,8 @@ export async function runMdq(argv, readStdin) {
29
29
  program.parse(argv, { from: 'user' });
30
30
  }
31
31
  catch (error) {
32
+ if (error.code === 'commander.helpDisplayed')
33
+ return { output: program.helpInformation(), code: 0 };
32
34
  return { output: String(error.message), code: 2 };
33
35
  }
34
36
  const options = program.opts();
@@ -1,16 +1,16 @@
1
1
  import { faker } from '@faker-js/faker';
2
2
  import { hopeThat, retryTo, tryTo, within } from 'codeceptjs/lib/effects';
3
3
  import step from 'codeceptjs/steps';
4
+ import { splitTopLevel } from "./code-extractor.js";
4
5
  const SHADOWED_GLOBALS = ['process', 'global', 'globalThis', 'fetch', 'Bun', 'require', 'module', 'exports'];
5
6
  const ALLOWED_COMMAND_HEADS = ['I.', 'page.', 'await page.', 'await I.'];
6
7
  const PLAYWRIGHT_COMMAND_HEADS = ['page.', 'await page.'];
7
8
  const PLAYWRIGHT_ARG_NAMES = ['page'];
8
9
  const CODECEPT_ARG_NAMES = ['I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker'];
9
10
  export function sanitizeCodeBlock(code) {
10
- return code
11
- .split('\n')
12
- .map((line) => line.trim())
13
- .filter((line) => ALLOWED_COMMAND_HEADS.some((head) => line.startsWith(head)))
11
+ return splitTopLevel(code, '\n')
12
+ .map((statement) => statement.trim())
13
+ .filter((statement) => ALLOWED_COMMAND_HEADS.some((head) => statement.startsWith(head)))
14
14
  .join('\n');
15
15
  }
16
16
  export function hasPlaywrightCommands(code) {
@@ -21,6 +21,19 @@ The collector starts at the path you give it. For each page it:
21
21
 
22
22
  It repeats this until the queue is empty or the page budget is spent. Only same-origin links are followed.
23
23
 
24
+ ## Research depth
25
+
26
+ In static mode the Researcher runs in a **light, single pass**: one AI call maps the sections and interactive elements, and the locator validation, repair conversations, and screenshot annotation passes of a full research run are skipped. Docs describe pages rather than click them, so unverified locators are a fair trade for roughly one AI call per page.
27
+
28
+ Interactive mode needs locators it can actually click, so it keeps the full pipeline: every locator is validated in the browser, broken ones get fixed, and a vision pass adds coordinates. Override the automatic choice with `research`:
29
+
30
+ ```ts
31
+ docs: {
32
+ research: 'full', // always run the verified pipeline
33
+ // research: 'light', // single pass even in interactive mode
34
+ }
35
+ ```
36
+
24
37
  ## Configure
25
38
 
26
39
  The collector runs on your regular Explorbot setup — the AI provider and browser come from `explorbot.config.js` (see [configuration](../reference/configuration.md)). Crawl behavior lives in a separate file: `docbot.config.ts` (`.js` and `.mjs` work too).
@@ -155,6 +155,14 @@ Expands hidden elements (dropdowns, accordions, tabs) to discover more UI. Click
155
155
 
156
156
  Extracts domain-specific content (articles, products, users) as structured data.
157
157
 
158
+ #### Light Research
159
+
160
+ ```ts
161
+ researcher.research(state, { light: true });
162
+ ```
163
+
164
+ A single-pass mode for consumers that describe pages instead of acting on them. Stage 1 runs as usual; stages 2–5 (locator testing, AI fixing, the visual annotation pass, backfills, and pagination probing) are skipped, so the whole run is one AI call with no browser verification roundtrips. Locators in the UI map come straight from the model and are not verified; a light run never writes to the shared research cache, so it cannot replace a verified result. The [doc collector](../doc-collection/basics.md#research-depth) uses this mode by default.
165
+
158
166
  ## Page Sections
159
167
 
160
168
  The Researcher breaks each page into sections by UI purpose. Sections are identified in priority order:
package/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
 
package/src/action.ts CHANGED
@@ -589,6 +589,10 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
589
589
  if (batchFailed) return;
590
590
 
591
591
  const executed: ExecutedStep = { command: step.toCode(), success: !error };
592
+ if (Object.keys(step.opts || {}).length) {
593
+ const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
594
+ executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
595
+ }
592
596
  if (error) {
593
597
  executed.error = errorToString(error);
594
598
  batchFailed = true;
package/src/ai/driller.ts CHANGED
@@ -6,6 +6,7 @@ import { setActivity } from '../activity.ts';
6
6
  import { Observability } from '../observability.ts';
7
7
  import { Plan, Test, TestResult } from '../test-plan.ts';
8
8
  import { collectInteractiveNodes } from '../utils/aria.ts';
9
+ import { splitTopLevel } from '../utils/code-extractor.ts';
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.ts';
10
11
  import { createDebug, tag } from '../utils/logger.ts';
11
12
  import { loop, pause } from '../utils/loop.ts';
@@ -1011,39 +1012,7 @@ function countTopLevelArgCount(line: string): number {
1011
1012
  const end = line.lastIndexOf(')');
1012
1013
  if (start === -1 || end === -1 || end <= start + 1) return 0;
1013
1014
 
1014
- const body = line.slice(start + 1, end);
1015
- let count = 1;
1016
- let depth = 0;
1017
- let quote = '';
1018
-
1019
- for (let i = 0; i < body.length; i++) {
1020
- const char = body[i];
1021
- const escaped = body[i - 1] === '\\';
1022
-
1023
- if (quote) {
1024
- if (char === quote && !escaped) quote = '';
1025
- continue;
1026
- }
1027
-
1028
- if (char === '"' || char === "'" || char === '`') {
1029
- quote = char;
1030
- continue;
1031
- }
1032
-
1033
- if (char === '(' || char === '[' || char === '{') {
1034
- depth++;
1035
- continue;
1036
- }
1037
-
1038
- if (char === ')' || char === ']' || char === '}') {
1039
- depth = Math.max(0, depth - 1);
1040
- continue;
1041
- }
1042
-
1043
- if (char === ',' && depth === 0) count++;
1044
- }
1045
-
1046
- return count;
1015
+ return splitTopLevel(line.slice(start + 1, end), ',').length;
1047
1016
  }
1048
1017
 
1049
1018
  function buildClassSelector(tag: string, classes: string[]): string {
@@ -136,11 +136,11 @@ class Navigator implements Agent {
136
136
  return matchesNavigationUrl(expectedUrl, currentUrl);
137
137
  }
138
138
 
139
- async visit(url: string): Promise<void> {
140
- return this.visitOnce(url);
139
+ async visit(url: string, opts: { recover?: boolean } = {}): Promise<boolean> {
140
+ return this.visitOnce(url, opts.recover !== false);
141
141
  }
142
142
 
143
- private async visitOnce(url: string): Promise<void> {
143
+ private async visitOnce(url: string, recover: boolean): Promise<boolean> {
144
144
  try {
145
145
  const action = this.explorer.action();
146
146
 
@@ -153,7 +153,10 @@ class Navigator implements Agent {
153
153
 
154
154
  await this.hooksRunner.runBeforeHook('navigator', url);
155
155
 
156
- if (!this.isOnExpectedPage(url, action.stateManager)) {
156
+ if (!recover && action.lastError) throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
157
+
158
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
159
+ if (recover && !arrived) {
157
160
  const actualPath = action.stateManager.getCurrentState()?.url || '';
158
161
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
159
162
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -177,6 +180,7 @@ class Navigator implements Agent {
177
180
  }
178
181
  await this.explorer.capture({ screenshot: true });
179
182
  await this.hooksRunner.runAfterHook('navigator', url);
183
+ return arrived || recover;
180
184
  } catch (error) {
181
185
  const errorMessage = error instanceof Error ? error.message : String(error);
182
186
  if (errorMessage.includes('ERR_CONNECTION_REFUSED')) {
@@ -284,7 +288,7 @@ class Navigator implements Agent {
284
288
  return;
285
289
  }
286
290
  tag('operation').log('Feeding failures back to AI for a new batch...');
287
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
291
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
288
292
  htmlContextAdded = true;
289
293
  codeBlocks = [];
290
294
  batchFailures.length = 0;
@@ -384,10 +388,6 @@ class Navigator implements Agent {
384
388
 
385
389
  <page>
386
390
  ${actionResult.toAiContext()}
387
-
388
- <page_html>
389
- ${await actionResult.combinedHtml()}
390
- </page_html>
391
391
  </page>
392
392
 
393
393
  <task>
@@ -397,6 +397,8 @@ class Navigator implements Agent {
397
397
  Propose different solutions to achieve the result.
398
398
  Solution should be valid CodeceptJS code.
399
399
  Use only data from the <page> context to plan the solution.
400
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
401
+ Full HTML is provided if these solutions fail.
400
402
  Try various ways to achieve the result
401
403
  </task>
402
404
 
@@ -813,7 +815,9 @@ class Navigator implements Agent {
813
815
 
814
816
  const verified = await action.attempt(codeBlock, message);
815
817
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
816
- results.push({ code: codeBlock, passed: verified, proof });
818
+ const result: AssertionResult = { code: codeBlock, passed: verified, proof };
819
+ if (!verified && action.lastError) result.error = action.lastError.message;
820
+ results.push(result);
817
821
 
818
822
  if (verified) {
819
823
  tag('success').log('Verification passed');
@@ -862,6 +866,6 @@ class Navigator implements Agent {
862
866
 
863
867
  type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
864
868
 
865
- export type AssertionResult = { code: string; passed: boolean; proof: string[] };
869
+ export type AssertionResult = { code: string; passed: boolean; proof: string[]; error?: string };
866
870
 
867
871
  export { Navigator };