Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 2026-07-17

### Changes
- Research no longer discards whole sections of a page when a container matches more than one element. A container only narrows down where child elements are looked up, so a selector matching every navigation bar or every card on the page is now perfectly usable — each child element inside it is still required to be unique. Previously such a section was declared broken and every element in it was sent back to the AI for repair.
- When a container matches nothing on the page, Explorbot now repairs it by looking at the elements it found inside it and working out their real wrapper, instead of asking the AI to guess a new selector. The section is fixed instantly and keeps working locators for its elements. When several sections share the same broken container and only some of them can be repaired this way, the remaining sections are still sent to the AI repair step instead of being wrongly treated as fixed.
- [Pilot] Pilot's instructions and its list of available tools now stay identical across every call in a session, with the scenario details moved to the end. Providers can reuse the prompt they already processed instead of re-reading it on each call, which lowers the cost of long test runs.
- [Planner] Planning rules and output format instructions now come before the page research, so planning more tests for the same page reuses an already-processed prompt.
- When a click matches several elements, the follow-up question that picks the right one now uses the default model instead of the more expensive agentic model.
Expand Down
11 changes: 5 additions & 6 deletions src/ai/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,10 @@ export class Researcher extends ResearcherBase implements Agent {
}

if (!interrupted()) {
const containerLocs = result.containerLocators;
await this.testLocators(containerLocs);
const brokenContainers = containerLocs.filter((l) => l.valid === false);
if (containerLocs.length > 0 && brokenContainers.length === containerLocs.length && retriesLeft > 0) {
tag('warning').log(`All ${containerLocs.length} containers broken, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
const brokenContainers = await this.resolveContainers(result);
const containerCount = result.containers.length;
if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
tag('warning').log(`All ${containerCount} containers broken, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
await new Promise((r) => setTimeout(r, 2000));
return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 } as any);
}
Expand Down Expand Up @@ -246,7 +245,7 @@ export class Researcher extends ResearcherBase implements Agent {
const validContainers = extractValidContainers(result.text);
result.parseLocators();
const freshContainerLocs = result.containerLocators;
await this.testLocators(freshContainerLocs);
await this.testLocators(freshContainerLocs, { scope: true });
const freshBroken = freshContainerLocs.filter((l) => l.valid === false).map((l) => l.locator);
const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
await this.visuallyAnnotateElements({ containers });
Expand Down
61 changes: 59 additions & 2 deletions src/ai/researcher/locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { parseAriaLocator } from '../../utils/aria.ts';
import { tag } from '../../utils/logger.js';
import { mdq } from '../../utils/markdown-query.ts';
import { WebElement } from '../../utils/web-element.ts';
import { isDynamicId } from '../../utils/xpath.ts';
import type { Conversation } from '../conversation.ts';
import type { Provider } from '../provider.js';
import { locatorRule as generalLocatorRuleText } from '../rules.js';
Expand Down Expand Up @@ -38,7 +39,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
declare provider: Provider;
declare actionResult: ActionResult | undefined;

async testLocators(locators: Locator[]): Promise<void> {
async testLocators(locators: Locator[], opts: { scope?: boolean } = {}): Promise<void> {
let broken = 0;
for (const loc of locators) {
if (executionController.isInterrupted()) break;
Expand All @@ -65,6 +66,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
return base.locator(loc.locator);
});
loc.valid = count === 1;
if (opts.scope && count > 1) loc.valid = true;
loc.pwLocator = buildPwLocatorString(loc);
if (!loc.valid) {
loc.error = count === 0 ? '0 elements' : `${count} elements`;
Expand Down Expand Up @@ -209,6 +211,60 @@ export function WithLocators<T extends Constructor>(Base: T) {
await this.validateContainers(result);
}

async resolveContainers(result: ResearchResult): Promise<Locator[]> {
const containerLocs = result.containerLocators;
await this.testLocators(containerLocs, { scope: true });
for (const loc of containerLocs.filter((l) => l.valid === false)) {
if (await this.recoverContainerFromChildren(result, loc.locator)) loc.valid = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returns true when only one section sharing this CSS is recovered, marking the container valid for all sections. Track recovery per section or return true only when every matching section is recovered

}
return containerLocs.filter((l) => l.valid === false);
}

private async recoverContainerFromChildren(result: ResearchResult, css: string): Promise<boolean> {
const sections = parseResearchSections(result.text).filter((s) => s.containerCss === css);
let recovered = 0;

for (const section of sections) {
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean) as string[];
if (eidxList.length < 2) continue;

const ancestor = await this.explorer.runWithBrowserRecovery('recoverContainerFromChildren', () => WebElement.commonAncestor(this.explorer.playwrightHelper.page, eidxList));
if (!ancestor) continue;

const candidates: string[] = [];
const id = ancestor.attrs.id;
if (id && !isDynamicId(id)) candidates.push(`#${id}`);
for (const cls of ancestor.filteredClasses) {
if (!/^[\w-]+$/.test(cls)) continue;
candidates.push(`${ancestor.tag}.${cls}`);
}

let unique: string | null = null;
let multiple: string | null = null;
for (const candidate of candidates) {
let count = 0;
try {
count = await this.explorer.playwrightLocatorCount((page) => page.locator(candidate));
} catch {}
if (count === 1) {
unique = candidate;
break;
}
if (count > 1 && !multiple) multiple = candidate;
}

const newCss = unique || multiple;
if (!newCss) continue;

debugLog(`Recovered container: '${css}' → '${newCss}' in "${section.name}"`);
this.updateSectionContainer(result, section, newCss);
recovered++;
}

if (recovered > 0 && recovered < sections.length) debugLog(`Container '${css}' recovered in ${recovered}/${sections.length} sections, still broken for the rest`);
return recovered > 0 && recovered === sections.length;
}

private async validateContainers(result: ResearchResult): Promise<void> {
const sections = parseResearchSections(result.text);

Expand Down Expand Up @@ -275,7 +331,8 @@ export interface Locator {
}

export interface LocatorMethods {
testLocators(locators: Locator[]): Promise<void>;
testLocators(locators: Locator[], opts?: { scope?: boolean }): Promise<void>;
fixBrokenSections(result: ResearchResult, conversation: Conversation): Promise<void>;
backfillBrokenLocators(result: ResearchResult): Promise<void>;
resolveContainers(result: ResearchResult): Promise<Locator[]>;
}
30 changes: 30 additions & 0 deletions src/utils/web-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ export class WebElement {
return rawList.map((d) => WebElement.fromRawData(d));
}

static async commonAncestor(page: any, eidxList: string[]): Promise<WebElement | null> {
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
if (validEidxList.length < 2) return null;

const raw: RawElementData | null = await page.evaluate(
([list, extractFnStr, config]: [string[], string, ElementExtractionConfig]) => {
const extract = new Function(`return ${extractFnStr}`)() as (el: Element, cfg: ElementExtractionConfig) => any;
const els: Element[] = [];
for (const eidx of list) {
const el = document.querySelector(`[${config.attrs.eidx}="${eidx}"]`);
if (el && !els.includes(el)) els.push(el);
}
if (els.length < 2) return null;

const containsAll = (node: Element) => els.every((el) => node.contains(el));
let ancestor: Element | null = els[0].parentElement;
while (ancestor && !containsAll(ancestor)) ancestor = ancestor.parentElement;
if (!ancestor) return null;

const tag = ancestor.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') return null;
return extract(ancestor, config);
},
[validEidxList, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG] as [string[], string, ElementExtractionConfig]
);

if (!raw) return null;
return WebElement.fromRawData(raw);
}

static async findByXPath(html: string, xpath: string): Promise<{ totalFound: number; elements: WebElement[]; error?: string }> {
const result = await evaluateXPath(html, xpath);
if (result.error) return { totalFound: 0, elements: [], error: result.error };
Expand Down
65 changes: 64 additions & 1 deletion tests/unit/web-element.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it } from 'bun:test';
import { JSDOM } from 'jsdom';
import { WebElement, extractElementData } from '../../src/utils/web-element.ts';

const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');

afterEach(() => {
restoreGlobal('window', originalWindow);
restoreGlobal('document', originalDocument);
});

describe('extractElementData', () => {
it('adds context, area, and variant hints for component drilling', () => {
const dom = new JSDOM(`
Expand Down Expand Up @@ -92,6 +100,61 @@ describe('WebElement', () => {
});
});

describe('WebElement.commonAncestor', () => {
it('returns the closest wrapper containing all elements', async () => {
const dom = new JSDOM(`
<main>
<div class="detail detail-view-resizable">
<button data-explorbot-eidx="e1">Save</button>
<span><a data-explorbot-eidx="e2" href="/cancel">Cancel</a></span>
</div>
</main>
`);
useDom(dom);
const wrapper = dom.window.document.querySelector('.detail')!;
mockVisibleBox(wrapper);

const ancestor = await WebElement.commonAncestor(fakePage(), ['e1', 'e2']);

expect(ancestor?.tag).toBe('div');
expect(ancestor?.filteredClasses).toContain('detail-view-resizable');
});

it('returns null for a single element', async () => {
const dom = new JSDOM(`
<main>
<div class="panel"><button data-explorbot-eidx="e1">Save</button></div>
</main>
`);
useDom(dom);

expect(await WebElement.commonAncestor(fakePage(), ['e1'])).toBeNull();
expect(await WebElement.commonAncestor(fakePage(), ['e1', 'e404'])).toBeNull();
});

it('returns null when elements only share the body', async () => {
const dom = new JSDOM(`
<header><button data-explorbot-eidx="e1">Save</button></header>
<footer><button data-explorbot-eidx="e2">Cancel</button></footer>
`);
useDom(dom);

expect(await WebElement.commonAncestor(fakePage(), ['e1', 'e2'])).toBeNull();
});
});

function fakePage() {
return { evaluate: async (fn: (arg: any) => any, arg: any) => fn(arg) };
}

function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined) {
if (!descriptor) {
delete (globalThis as any)[name];
return;
}
Object.defineProperty(globalThis, name, descriptor);
}

function useDom(dom: JSDOM) {
(globalThis as any).window = dom.window;
(globalThis as any).document = dom.window.document;
Expand Down
Loading