diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c88ba..fd07835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/ai/researcher.ts b/src/ai/researcher.ts index 98239a2..288f283 100644 --- a/src/ai/researcher.ts +++ b/src/ai/researcher.ts @@ -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); } @@ -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 }); diff --git a/src/ai/researcher/locators.ts b/src/ai/researcher/locators.ts index 62fa733..c729ce0 100644 --- a/src/ai/researcher/locators.ts +++ b/src/ai/researcher/locators.ts @@ -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'; @@ -38,7 +39,7 @@ export function WithLocators(Base: T) { declare provider: Provider; declare actionResult: ActionResult | undefined; - async testLocators(locators: Locator[]): Promise { + async testLocators(locators: Locator[], opts: { scope?: boolean } = {}): Promise { let broken = 0; for (const loc of locators) { if (executionController.isInterrupted()) break; @@ -65,6 +66,7 @@ export function WithLocators(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`; @@ -209,6 +211,60 @@ export function WithLocators(Base: T) { await this.validateContainers(result); } + async resolveContainers(result: ResearchResult): Promise { + 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; + } + return containerLocs.filter((l) => l.valid === false); + } + + private async recoverContainerFromChildren(result: ResearchResult, css: string): Promise { + 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 { const sections = parseResearchSections(result.text); @@ -275,7 +331,8 @@ export interface Locator { } export interface LocatorMethods { - testLocators(locators: Locator[]): Promise; + testLocators(locators: Locator[], opts?: { scope?: boolean }): Promise; fixBrokenSections(result: ResearchResult, conversation: Conversation): Promise; backfillBrokenLocators(result: ResearchResult): Promise; + resolveContainers(result: ResearchResult): Promise; } diff --git a/src/utils/web-element.ts b/src/utils/web-element.ts index fc30086..fc2ce33 100644 --- a/src/utils/web-element.ts +++ b/src/utils/web-element.ts @@ -147,6 +147,36 @@ export class WebElement { return rawList.map((d) => WebElement.fromRawData(d)); } + static async commonAncestor(page: any, eidxList: string[]): Promise { + 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 }; diff --git a/tests/unit/web-element.test.ts b/tests/unit/web-element.test.ts index d631177..0af6600 100644 --- a/tests/unit/web-element.test.ts +++ b/tests/unit/web-element.test.ts @@ -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(` @@ -92,6 +100,61 @@ describe('WebElement', () => { }); }); +describe('WebElement.commonAncestor', () => { + it('returns the closest wrapper containing all elements', async () => { + const dom = new JSDOM(` +
+
+ + Cancel +
+
+ `); + 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(` +
+
+
+ `); + 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(` +
+ + `); + 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;