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
5 changes: 2 additions & 3 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,9 @@ async function handleEvaluate(
command: EvaluateCommand,
browser: BrowserManager
): Promise<Response<EvaluateData>> {
const page = browser.getPage();
const frame = browser.getFrame();

// Evaluate the script directly as a string expression
const result = await page.evaluate(command.script);
const result = await frame.evaluate(command.script);

return successResponse(command.id, { result });
}
Expand Down
75 changes: 74 additions & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import { BrowserManager } from './browser.js';
import { chromium } from 'playwright-core';

Expand Down Expand Up @@ -661,4 +661,77 @@ describe('BrowserManager', () => {
).resolves.not.toThrow();
});
});

describe('frame switch', () => {
const IFRAME_HTML = `
<div id="main-content">Main document</div>
<iframe id="test-iframe" srcdoc="<h1>Iframe heading</h1><button id='frame-btn'>Frame button</button>"></iframe>
`;

beforeEach(async () => {
const page = browser.getPage();
await page.setContent(IFRAME_HTML, { waitUntil: 'load' });
browser.switchToMainFrame();
});

it('should return main frame when no frame selected', () => {
browser.switchToMainFrame();
const frame = browser.getFrame();
const page = browser.getPage();
expect(frame).toBe(page.mainFrame());
});

it('should switch to iframe by selector and snapshot shows iframe content', async () => {
await browser.switchToFrame({ selector: '#test-iframe' });
const { tree } = await browser.getSnapshot();
expect(tree).toContain('Iframe heading');
expect(tree).toContain('Frame button');
expect(tree).not.toContain('Main document');
});

it('should run eval in active frame context', async () => {
await browser.switchToFrame({ selector: '#test-iframe' });
const frame = browser.getFrame();
const result = await frame.evaluate(() => document.querySelector('h1')?.textContent);
expect(result).toBe('Iframe heading');
});

it('should resolve locators within active frame', async () => {
await browser.switchToFrame({ selector: '#test-iframe' });
const locator = browser.getLocator('#frame-btn');
const text = await locator.textContent();
expect(text).toBe('Frame button');
});

it('should support nested iframe switch (selector within current frame)', async () => {
const nestedHtml = `
<div>Outer iframe</div>
<iframe id="inner" srcdoc="<p>Inner content</p>"></iframe>
`;
const page = browser.getPage();
await page.setContent(
`<iframe id="outer" srcdoc="${nestedHtml.replace(/"/g, '&quot;')}"></iframe>`,
{ waitUntil: 'load' }
);

await browser.switchToFrame({ selector: '#outer' });
const { tree } = await browser.getSnapshot();
expect(tree).toContain('Outer iframe');

await browser.switchToFrame({ selector: '#inner' });
const { tree: innerTree } = await browser.getSnapshot();
expect(innerTree).toContain('Inner content');
});

it('should return to main document after switchToMainFrame', async () => {
await browser.switchToFrame({ selector: '#test-iframe' });
const { tree: iframeTree } = await browser.getSnapshot();
expect(iframeTree).toContain('Iframe heading');

browser.switchToMainFrame();
const { tree: mainTree } = await browser.getSnapshot();
expect(mainTree).toContain('Main document');
expect(mainTree).not.toContain('Iframe heading');
});
});
});
21 changes: 12 additions & 9 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ export class BrowserManager {
compact?: boolean;
selector?: string;
}): Promise<EnhancedSnapshot> {
const page = this.getPage();
const snapshot = await getEnhancedSnapshot(page, options);
const frame = this.getFrame();
const snapshot = await getEnhancedSnapshot(frame, options);
this.refMap = snapshot.refs;
this.lastSnapshot = snapshot.tree;
return snapshot;
Expand All @@ -144,14 +144,14 @@ export class BrowserManager {
const refData = this.refMap[ref];
if (!refData) return null;

const page = this.getPage();
const frame = this.getFrame();

// Build locator with exact: true to avoid substring matches
let locator: Locator;
if (refData.name) {
locator = page.getByRole(refData.role as any, { name: refData.name, exact: true });
locator = frame.getByRole(refData.role as any, { name: refData.name, exact: true });
} else {
locator = page.getByRole(refData.role as any);
locator = frame.getByRole(refData.role as any);
}

// If an nth index is stored (for disambiguation), use it
Expand All @@ -178,8 +178,8 @@ export class BrowserManager {
if (locator) return locator;

// Otherwise treat as regular selector
const page = this.getPage();
return page.locator(selectorOrRef);
const frame = this.getFrame();
return frame.locator(selectorOrRef);
}

/**
Expand All @@ -203,13 +203,16 @@ export class BrowserManager {
}

/**
* Switch to a frame by selector, name, or URL
* Switch to a frame by selector, name, or URL.
* Selector is resolved within the current frame (supports nested iframes).
* Name and URL search the entire frame tree.
*/
async switchToFrame(options: { selector?: string; name?: string; url?: string }): Promise<void> {
const page = this.getPage();
const currentFrame = this.getFrame();

if (options.selector) {
const frameElement = await page.$(options.selector);
const frameElement = await currentFrame.$(options.selector);
if (!frameElement) {
throw new Error(`Frame not found: ${options.selector}`);
}
Expand Down
13 changes: 8 additions & 5 deletions src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* agent-browser click @e2 # Click element by ref
*/

import type { Page, Locator } from 'playwright-core';
import type { Page, Frame, Locator } from 'playwright-core';

export interface RefMap {
[ref: string]: {
Expand Down Expand Up @@ -137,17 +137,20 @@ function buildSelector(role: string, name?: string): string {
}

/**
* Get enhanced snapshot with refs and optional filtering
* Get enhanced snapshot with refs and optional filtering.
* Accepts Page or Frame so snapshots can be scoped to the active frame (e.g. iframe).
*/
export async function getEnhancedSnapshot(
page: Page,
pageOrFrame: Page | Frame,
options: SnapshotOptions = {}
): Promise<EnhancedSnapshot> {
resetRefs();
const refs: RefMap = {};

// Get ARIA snapshot from Playwright
const locator = options.selector ? page.locator(options.selector) : page.locator(':root');
// Get ARIA snapshot from Playwright (works for both Page and Frame)
const locator = options.selector
? pageOrFrame.locator(options.selector)
: pageOrFrame.locator(':root');
const ariaTree = await locator.ariaSnapshot();

if (!ariaTree) {
Expand Down