diff --git a/CHANGELOG.md b/CHANGELOG.md index b7092bab..24435bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-11-06 + +### Changes +- [Researcher] Closes cookie consent, paywall, marketing and similar popups before researching a page. Regular dialogs stay open and are researched as before. +- [Planner] No longer plans tests for cookie consent, marketing, announcement or notification popups, unless you plan that feature explicitly. + ## 2026-11-05 ### New CLI Options diff --git a/rules/planner/ignored-pages.md b/rules/planner/ignored-pages.md index a2f38500..4bb2ef6a 100644 --- a/rules/planner/ignored-pages.md +++ b/rules/planner/ignored-pages.md @@ -4,4 +4,5 @@ Do not plan tests for these pages, return an empty scenarios list: - sign up - password recovery If the page shows a sign-in form instead of the content its URL is for, also set prerequisite. +Never plan tests for popups unrelated to the application's features (cookie consent, marketing, announcement, notification), even when marked as Focused or active. Plan for the rest of the page as if they were absent. diff --git a/src/ai/researcher.ts b/src/ai/researcher.ts index 734711d8..03c1d28b 100644 --- a/src/ai/researcher.ts +++ b/src/ai/researcher.ts @@ -30,6 +30,7 @@ import { type PaginationMethods, WithPagination } from './researcher/pagination. import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts'; import { ResearchResult } from './researcher/research-result.ts'; import { type SectionMethods, WithSections } from './researcher/sections.ts'; +import { type WallMethods, WithWalls } from './researcher/walls.ts'; import { locatorRule as generalLocatorRuleText } from './rules.js'; import { TaskAgent } from './task-agent.ts'; @@ -48,9 +49,9 @@ export const POSSIBLE_SECTIONS = { navigation: 'main navigation (top bar, sidebar, breadcrumbs)', }; -const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))))); +const ResearcherBase = WithWalls(WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)))))); -export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods {} +export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods, WallMethods {} export class Researcher extends ResearcherBase implements Agent { protected readonly ACTION_TOOLS = ['click']; @@ -158,6 +159,7 @@ export class Researcher extends ResearcherBase implements Agent { tag('warning').log(`Page at ${state.url} did not finish loading within timeout, continuing with best-effort research`); } } + if (this.actionResult!.overlay.isModal) await this.closeWall(screenshot); debugLog('Researching web page:', this.actionResult!.url); diff --git a/src/ai/researcher/walls.ts b/src/ai/researcher/walls.ts new file mode 100644 index 00000000..a7ec4c67 --- /dev/null +++ b/src/ai/researcher/walls.ts @@ -0,0 +1,28 @@ +import type { ActionResult } from '../../action-result.ts'; +import type Explorer from '../../explorer.ts'; +import { pageEvidence } from '../judge.ts'; +import type { Provider } from '../provider.ts'; +import type { Constructor } from './mixin.ts'; + +const WALL_STATEMENT = "The open overlay is unrelated to the application's features, such as cookie consent, a paywall, marketing or an announcement. A sign-in form is not such an overlay."; + +export function WithWalls(Base: T) { + return class extends Base { + declare explorer: Explorer; + declare provider: Provider; + declare actionResult: ActionResult | undefined; + + async closeWall(screenshot: boolean): Promise { + const page = this.actionResult!; + const isWall = await this.provider.oracle(WALL_STATEMENT, pageEvidence(page), { agentName: 'researcher', screenshot: page.screenshot }); + if (!isWall) return; + + await (this as any).cancelInUi(); + this.actionResult = await this.explorer.capture({ screenshot: screenshot && this.provider.hasVision() }); + } + }; +} + +export interface WallMethods { + closeWall(screenshot: boolean): Promise; +} diff --git a/tests/integration/researcher.test.ts b/tests/integration/researcher.test.ts index fa75a302..8ade2e7a 100644 --- a/tests/integration/researcher.test.ts +++ b/tests/integration/researcher.test.ts @@ -284,6 +284,32 @@ describe('Researcher with aimock', () => { expect(deepRuns).toBe(1); }); + it('closes an overlay only when the oracle says it is a wall', async () => { + const wallState = { ...fakeState, ariaSnapshot: `${fakeState.ariaSnapshot}\n- dialog "Cookie settings":\n - button "Accept"` }; + const isWallQuestion = (req: any) => JSON.stringify(req.messages || []).includes('open overlay is unrelated'); + const researcher = new Researcher({ ...createMockDeps(wallState), ai: provider } as any); + let closed = 0; + (researcher as any).cancelInUi = async () => { + closed++; + }; + + for (const holds of [false, true]) { + mock.clearFixtures(); + mock.on({ predicate: isWallQuestion }, { content: JSON.stringify({ holds }) }); + mock.on({}, { content: taskBoardResearch }); + await researcher.research(wallState, { force: true, fix: false }); + } + + expect(mock.getRequests().filter((r: any) => isWallQuestion(r.body))).toHaveLength(2); + expect(closed).toBe(1); + }); + + it('does not ask the oracle about a wall when no overlay is detected', async () => { + await researcher.research(fakeState, { force: true, fix: false }); + + expect(JSON.stringify(mock.getRequests().map((r: any) => r.body))).not.toContain('open overlay is unrelated'); + }); + it('light research accepts a page without locators instead of retrying', async () => { mock.clearFixtures(); mock.on({}, { content: '# Informational page\n\nStatic text without interactive elements.' });