Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 2026-11-07

### Changes
- [Researcher] Opens one item from each list on the page, such as a product card, so controls that only appear inside it are found. It only opens links and controls marked as opening something, never unlabelled row buttons like Delete.
- [Planner] Plans tests for actions that live inside a list item. On a store whose Add to cart button sits in the product dialog, the plan now covers adding to the cart.

## 2026-11-06

### Changes
Expand Down
2 changes: 1 addition & 1 deletion src/ai/driller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,6 @@ function isInteractiveElement(element: WebElement): boolean {
if (HTML_INTERACTIVE_ROLES.has(role)) return true;
if (element.attrs.contenteditable === 'true') return true;
if (element.attrs.tabindex && Number(element.attrs.tabindex) >= 0) return true;
if (element.attrs['aria-haspopup'] || element.attrs['aria-expanded'] || element.attrs['aria-controls']) return true;
if (element.revealsContent) return true;
return false;
}
27 changes: 26 additions & 1 deletion src/ai/researcher/deep-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { extractCodeBlocks } from '../../utils/code-extractor.ts';
import { tag } from '../../utils/logger.js';
import { mdq } from '../../utils/markdown-query.ts';
import { truncate } from '../../utils/strings.ts';
import { annotatePageElements } from '../../utils/web-annotate.ts';
import { eidxInContainer } from '../../utils/web-eidx.ts';
import type { Provider } from '../provider.js';
import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
import { type Constructor, debugLog } from './mixin.ts';
import { type ResearchElement, parseResearchSections } from './parser.ts';
import { type ResearchElement, parseDataSections, parseResearchSections } from './parser.ts';
import type { ResearchResult } from './research-result.ts';

const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
Expand Down Expand Up @@ -60,6 +62,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
}
expandables.push(...(await this._findDataItems(result.text)));

const elements = expandables
.map((el) => ({
Expand Down Expand Up @@ -296,6 +299,28 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
return [...eidxSet].map((eidx) => allElements.get(eidx)!);
}

private async _findDataItems(researchText: string): Promise<ExpandableElement[]> {
const sections = parseDataSections(researchText).filter((section) => section.containerCss);
if (sections.length === 0) return [];

const { elements } = await this.explorer.withPage(annotatePageElements);
await this.explorer.capture();
const byEidx = new Map(elements.map((el) => [el.eidx, el]));

const items: ExpandableElement[] = [];
for (const section of sections) {
const eidxList: string[] = await this.explorer.withPage((page) => eidxInContainer(page, section.containerCss)).catch(() => []);
const item = eidxList.map((eidx) => byEidx.get(eidx)).find((el) => el?.declaresDestination);
if (!item) continue;

const label = item.attrs['aria-label'] || item.text;
let aria: ResearchElement['aria'] = null;
if (item.role && label) aria = { role: item.role, text: label };
items.push({ name: label || section.name, type: item.role, aria, css: item.clickXPath, xpath: null, coordinates: null, color: null, icon: null, eidx: item.eidx, container: section.containerCss });
}
return items;
}

private _buildClickCommands(el: ExpandableElement): string[] {
const commands: string[] = [];
const escapeQuote = (s: string) => s.replace(/'/g, "\\'");
Expand Down
9 changes: 9 additions & 0 deletions src/utils/web-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { extractElementData } from './html.ts';

const KEY_DISPLAY_ATTRS = ['role', 'id', 'class', 'aria-label'];
const KEY_ATTRS = ['role', 'aria-label', 'id', 'name', 'type', 'href'];
const REVEALING_ATTRS = ['aria-haspopup', 'aria-expanded', 'aria-controls'];

export class WebElement {
tag: string;
Expand Down Expand Up @@ -57,6 +58,14 @@ export class WebElement {
return !!href && href !== '#' && !href.startsWith('javascript:');
}

get revealsContent(): boolean {
return REVEALING_ATTRS.some((attr) => this.attrs[attr]);
}

get declaresDestination(): boolean {
return this.isNavigationLink || this.revealsContent;
}

get filteredClasses(): string[] {
const cls = this.attrs.class || '';
return cls.split(/\s+/).filter((c) => c.length > 2 && !isDynamicId(c) && !isGenericClass(c));
Expand Down
105 changes: 105 additions & 0 deletions tests/integration/deep-analysis-data-items-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { type Browser, type Page, chromium } from 'playwright';
import { WithDeepAnalysis } from '../../src/ai/researcher/deep-analysis.ts';

const DeepAnalysis = WithDeepAnalysis(Object as any) as any;

const STATE = { url: '/list', hash: '', html: '<html><body></body></html>', ariaSnapshot: '' };

function dataSection(container: string): string {
return `## Data: Items\n\n> Container: \`${container}\`\n\nItems, 2 items.\n`;
}

describe('deep analysis of Data sections', () => {
let browser: Browser;
let page: Page;

beforeAll(async () => {
browser = await chromium.launch();
page = await browser.newPage();
});

afterAll(async () => {
await browser.close();
});

async function attemptedClicks(html: string, research: string): Promise<string[]> {
await page.setContent(html);
const attempted: string[] = [];
const analysis = new DeepAnalysis();
analysis.config = { ai: {} };
analysis.provider = { getModelForAgent: () => null, chat: async () => ({ text: '' }), hasVision: () => false };
analysis.stateManager = { getCurrentState: () => STATE };
analysis.navigateTo = async () => {};
analysis.cancelInUi = async () => {};
analysis.explorer = {
withPage: async (fn: any) => fn(page),
capture: async () => {},
action: () => ({
attempt: async (code: string) => {
if (code.startsWith('I.click')) attempted.push(code);
return false;
},
}),
};
await analysis.performDeepAnalysis(STATE, { text: research });
return attempted;
}

it('opens one item when the item declares a popup', async () => {
const clicks = await attemptedClicks(
`<div id="grid">
<article role="button" aria-haspopup="dialog" aria-label="View first item" tabindex="0">First</article>
<article role="button" aria-haspopup="dialog" aria-label="View second item" tabindex="0">Second</article>
</div>`,
dataSection('#grid')
);

expect(clicks.length).toBeGreaterThan(0);
expect(clicks.some((code) => code.includes('View first item'))).toBe(true);
expect(clicks.some((code) => code.includes('View second item'))).toBe(false);
});

it('follows the first link of a list', async () => {
const clicks = await attemptedClicks(
`<ul id="list">
<li><a href="/items/1">First record</a></li>
<li><a href="/items/2">Second record</a></li>
</ul>`,
dataSection('#list')
);

expect(clicks.some((code) => code.includes('First record'))).toBe(true);
expect(clicks.some((code) => code.includes('Second record'))).toBe(false);
});

it('never presses an item control that does not declare where it leads', async () => {
const clicks = await attemptedClicks(
`<table id="rows"><tbody>
<tr><td>First</td><td><button>Delete</button></td></tr>
<tr><td>Second</td><td><button>Delete</button></td></tr>
</tbody></table>`,
dataSection('#rows')
);

expect(clicks).toEqual([]);
});

it('skips a safe control when it sits after an unsafe one', async () => {
const clicks = await attemptedClicks(
`<ul id="list">
<li><button>Archive</button><a href="/items/1">First record</a></li>
</ul>`,
dataSection('#list')
);

expect(clicks.some((code) => code.includes('Archive'))).toBe(false);
expect(clicks.some((code) => code.includes('First record'))).toBe(true);
});

it('ignores a Data section whose container is not on the page', async () => {
const clicks = await attemptedClicks('<a href="/elsewhere">Elsewhere</a>', dataSection('#gone'));

expect(clicks).toEqual([]);
});
});
Loading