@ulpi/browse 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -14,6 +14,7 @@ import { SessionManager, type Session } from './session-manager';
14
14
  import { handleReadCommand } from './commands/read';
15
15
  import { handleWriteCommand } from './commands/write';
16
16
  import { handleMetaCommand } from './commands/meta';
17
+ import { PolicyChecker } from './policy';
17
18
  import { DEFAULTS } from './constants';
18
19
  import { type LogEntry, type NetworkEntry } from './buffers';
19
20
  import * as fs from 'fs';
@@ -26,7 +27,8 @@ export { type LogEntry, type NetworkEntry };
26
27
  // ─── Auth (inline) ─────────────────────────────────────────────
27
28
  const AUTH_TOKEN = crypto.randomUUID();
28
29
  const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
29
- const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
30
+ const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
31
+ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
30
32
  const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
31
33
  const STATE_FILE = process.env.BROWSE_STATE_FILE || `${LOCAL_DIR}/browse-server${INSTANCE_SUFFIX}.json`;
32
34
  const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULTS.IDLE_TIMEOUT_MS), 10);
@@ -46,9 +48,8 @@ function flushAllBuffers(sessionManager: SessionManager, final = false) {
46
48
  }
47
49
 
48
50
  function flushSessionBuffers(session: Session, final: boolean) {
49
- const suffix = session.id === 'default' ? INSTANCE_SUFFIX : `-${session.id}`;
50
- const consolePath = `${LOCAL_DIR}/browse-console${suffix}.log`;
51
- const networkPath = `${LOCAL_DIR}/browse-network${suffix}.log`;
51
+ const consolePath = `${session.outputDir}/console.log`;
52
+ const networkPath = `${session.outputDir}/network.log`;
52
53
  const buffers = session.buffers;
53
54
 
54
55
  // Console flush
@@ -98,19 +99,25 @@ function flushSessionBuffers(session: Session, final: boolean) {
98
99
  let sessionManager: SessionManager;
99
100
  let browser: Browser;
100
101
  let isShuttingDown = false;
102
+ let isRemoteBrowser = false;
103
+ const policyChecker = new PolicyChecker();
101
104
 
102
105
  // Read/write/meta command sets for routing
103
106
  const READ_COMMANDS = new Set([
104
107
  'text', 'html', 'links', 'forms', 'accessibility',
105
- 'js', 'eval', 'css', 'attrs', 'state', 'dialog',
108
+ 'js', 'eval', 'css', 'attrs', 'element-state', 'dialog',
106
109
  'console', 'network', 'cookies', 'storage', 'perf', 'devices',
110
+ 'value', 'count',
107
111
  ]);
108
112
 
109
113
  const WRITE_COMMANDS = new Set([
110
114
  'goto', 'back', 'forward', 'reload',
111
- 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
115
+ 'click', 'dblclick', 'fill', 'select', 'hover', 'focus', 'check', 'uncheck',
116
+ 'type', 'press', 'scroll', 'wait',
112
117
  'viewport', 'cookie', 'header', 'useragent',
113
118
  'upload', 'dialog-accept', 'dialog-dismiss', 'emulate',
119
+ 'drag', 'keydown', 'keyup',
120
+ 'highlight', 'download', 'route', 'offline',
114
121
  ]);
115
122
 
116
123
  const META_COMMANDS = new Set([
@@ -120,42 +127,133 @@ const META_COMMANDS = new Set([
120
127
  'chain', 'diff',
121
128
  'url', 'snapshot', 'snapshot-diff',
122
129
  'sessions', 'session-close',
130
+ 'frame', 'state',
131
+ 'auth', 'har',
123
132
  ]);
124
133
 
134
+ // Probe if a port is free using net.createServer (not Bun.serve which fatally crashes on EADDRINUSE)
135
+ import * as net from 'net';
136
+
137
+ function isPortFree(port: number): Promise<boolean> {
138
+ return new Promise((resolve) => {
139
+ const srv = net.createServer();
140
+ srv.once('error', () => resolve(false));
141
+ srv.once('listening', () => { srv.close(() => resolve(true)); });
142
+ srv.listen(port, '127.0.0.1');
143
+ });
144
+ }
145
+
125
146
  // Find port: use BROWSE_PORT or scan range
126
147
  async function findPort(): Promise<number> {
127
148
  if (BROWSE_PORT) {
128
- try {
129
- const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
130
- testServer.stop();
131
- return BROWSE_PORT;
132
- } catch {
133
- throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
134
- }
149
+ if (await isPortFree(BROWSE_PORT)) return BROWSE_PORT;
150
+ throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
135
151
  }
136
152
 
137
153
  // Scan range
138
154
  const start = parseInt(process.env.BROWSE_PORT_START || String(DEFAULTS.PORT_RANGE_START), 10);
139
155
  const end = start + (DEFAULTS.PORT_RANGE_END - DEFAULTS.PORT_RANGE_START);
140
156
  for (let port = start; port <= end; port++) {
141
- try {
142
- const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
143
- testServer.stop();
144
- return port;
145
- } catch {
146
- continue;
147
- }
157
+ if (await isPortFree(port)) return port;
148
158
  }
149
159
  throw new Error(`[browse] No available port in range ${start}-${end}`);
150
160
  }
151
161
 
152
- async function handleCommand(body: any, session: Session): Promise<Response> {
162
+ // Commands that return page-derived content (for --content-boundaries wrapping).
163
+ // Action commands (click, goto) and meta commands (status, tabs) are NOT wrapped.
164
+ const PAGE_CONTENT_COMMANDS = new Set([
165
+ 'text', 'html', 'links', 'forms', 'accessibility',
166
+ 'js', 'eval', 'console', 'network', 'snapshot',
167
+ ]);
168
+
169
+ // Nonce for content boundaries — generated once per server process
170
+ const BOUNDARY_NONCE = crypto.randomUUID();
171
+
172
+ interface RequestOptions {
173
+ jsonMode: boolean;
174
+ contentBoundaries: boolean;
175
+ }
176
+
177
+ /**
178
+ * Rewrite Playwright error messages into actionable hints for AI agents.
179
+ * Raw errors like "locator.click: Timeout 5000ms exceeded" are unhelpful.
180
+ */
181
+ function rewriteError(msg: string): string {
182
+ if (msg.includes('strict mode violation')) {
183
+ const countMatch = msg.match(/resolved to (\d+) elements/);
184
+ return `Multiple elements matched (${countMatch?.[1] || 'several'}). Use a more specific selector or run 'snapshot -i' to find exact refs.`;
185
+ }
186
+ if (msg.includes('Timeout') && msg.includes('exceeded')) {
187
+ const timeMatch = msg.match(/Timeout (\d+)ms/);
188
+ return `Element not found within ${timeMatch?.[1] || '?'}ms. The element may not exist, be hidden, or the page is still loading. Try 'wait <selector>' first, or check with 'snapshot -i'.`;
189
+ }
190
+ if (msg.includes('waiting for locator') || msg.includes('waiting for selector')) {
191
+ return `Element not found on the page. Run 'snapshot -i' to see available elements, or check the current URL with 'url'.`;
192
+ }
193
+ if (msg.includes('not an HTMLInputElement') || msg.includes('not an input')) {
194
+ return `Cannot fill this element — it's not an input field. Use 'click' instead, or run 'snapshot -i' to find the correct input.`;
195
+ }
196
+ if (msg.includes('Element is not visible')) {
197
+ return `Element exists but is hidden (display:none or visibility:hidden). Try scrolling to it with 'scroll <selector>' or wait for it with 'wait <selector>'.`;
198
+ }
199
+ if (msg.includes('Element is outside of the viewport')) {
200
+ return `Element is off-screen. Scroll to it first with 'scroll <selector>'.`;
201
+ }
202
+ if (msg.includes('intercepts pointer events')) {
203
+ return `Another element is covering the target (e.g., a modal, overlay, or cookie banner). Close the overlay first or use 'js' to click directly.`;
204
+ }
205
+ if (msg.includes('Frame was detached') || msg.includes('frame was detached')) {
206
+ return `The iframe was removed or navigated away. Run 'frame main' to return to the main page, then re-navigate.`;
207
+ }
208
+ if (msg.includes('Target closed') || msg.includes('target closed')) {
209
+ return `The page or tab was closed. Use 'tabs' to list open tabs, or 'goto' to navigate to a new page.`;
210
+ }
211
+ if (msg.includes('net::ERR_')) {
212
+ const errMatch = msg.match(/(net::\w+)/);
213
+ return `Network error: ${errMatch?.[1] || 'connection failed'}. Check the URL and ensure the site is reachable.`;
214
+ }
215
+ return msg;
216
+ }
217
+
218
+ async function handleCommand(body: any, session: Session, opts: RequestOptions): Promise<Response> {
153
219
  const { command, args = [] } = body;
154
220
 
155
221
  if (!command) {
156
- return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
157
- status: 400,
158
- headers: { 'Content-Type': 'application/json' },
222
+ const error = 'Missing "command" field';
223
+ if (opts.jsonMode) {
224
+ return new Response(JSON.stringify({ success: false, error }), {
225
+ status: 400, headers: { 'Content-Type': 'application/json' },
226
+ });
227
+ }
228
+ return new Response(JSON.stringify({ error }), {
229
+ status: 400, headers: { 'Content-Type': 'application/json' },
230
+ });
231
+ }
232
+
233
+ // Policy check
234
+ const policyResult = policyChecker.check(command);
235
+ if (policyResult === 'deny') {
236
+ const error = `Command '${command}' denied by policy`;
237
+ const hint = 'Update browse-policy.json to allow this command.';
238
+ if (opts.jsonMode) {
239
+ return new Response(JSON.stringify({ success: false, error, hint }), {
240
+ status: 403, headers: { 'Content-Type': 'application/json' },
241
+ });
242
+ }
243
+ return new Response(JSON.stringify({ error, hint }), {
244
+ status: 403, headers: { 'Content-Type': 'application/json' },
245
+ });
246
+ }
247
+ if (policyResult === 'confirm') {
248
+ const error = `Command '${command}' requires confirmation (policy). Non-interactive CLI cannot confirm.`;
249
+ const hint = 'Move this command to the allow list in browse-policy.json.';
250
+ if (opts.jsonMode) {
251
+ return new Response(JSON.stringify({ success: false, error, hint }), {
252
+ status: 403, headers: { 'Content-Type': 'application/json' },
253
+ });
254
+ }
255
+ return new Response(JSON.stringify({ error, hint }), {
256
+ status: 403, headers: { 'Content-Type': 'application/json' },
159
257
  });
160
258
  }
161
259
 
@@ -165,27 +263,47 @@ async function handleCommand(body: any, session: Session): Promise<Response> {
165
263
  if (READ_COMMANDS.has(command)) {
166
264
  result = await handleReadCommand(command, args, session.manager, session.buffers);
167
265
  } else if (WRITE_COMMANDS.has(command)) {
168
- result = await handleWriteCommand(command, args, session.manager);
266
+ result = await handleWriteCommand(command, args, session.manager, session.domainFilter);
169
267
  } else if (META_COMMANDS.has(command)) {
170
268
  result = await handleMetaCommand(command, args, session.manager, shutdown, sessionManager, session);
171
269
  } else {
172
- return new Response(JSON.stringify({
173
- error: `Unknown command: ${command}`,
174
- hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
175
- }), {
176
- status: 400,
177
- headers: { 'Content-Type': 'application/json' },
270
+ const error = `Unknown command: ${command}`;
271
+ const hint = `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`;
272
+ if (opts.jsonMode) {
273
+ return new Response(JSON.stringify({ success: false, error, hint }), {
274
+ status: 400, headers: { 'Content-Type': 'application/json' },
275
+ });
276
+ }
277
+ return new Response(JSON.stringify({ error, hint }), {
278
+ status: 400, headers: { 'Content-Type': 'application/json' },
279
+ });
280
+ }
281
+
282
+ // Apply content boundaries for page-content commands
283
+ if (opts.contentBoundaries && PAGE_CONTENT_COMMANDS.has(command)) {
284
+ const origin = session.manager.getCurrentUrl();
285
+ result = `--- BROWSE_CONTENT nonce=${BOUNDARY_NONCE} origin=${origin} ---\n${result}\n--- END_BROWSE_CONTENT nonce=${BOUNDARY_NONCE} ---`;
286
+ }
287
+
288
+ // Apply JSON wrapping
289
+ if (opts.jsonMode) {
290
+ return new Response(JSON.stringify({ success: true, data: result, command }), {
291
+ status: 200, headers: { 'Content-Type': 'application/json' },
178
292
  });
179
293
  }
180
294
 
181
295
  return new Response(result, {
182
- status: 200,
183
- headers: { 'Content-Type': 'text/plain' },
296
+ status: 200, headers: { 'Content-Type': 'text/plain' },
184
297
  });
185
298
  } catch (err: any) {
186
- return new Response(JSON.stringify({ error: err.message }), {
187
- status: 500,
188
- headers: { 'Content-Type': 'application/json' },
299
+ const friendlyError = rewriteError(err.message);
300
+ if (opts.jsonMode) {
301
+ return new Response(JSON.stringify({ success: false, error: friendlyError, command }), {
302
+ status: 500, headers: { 'Content-Type': 'application/json' },
303
+ });
304
+ }
305
+ return new Response(JSON.stringify({ error: friendlyError }), {
306
+ status: 500, headers: { 'Content-Type': 'application/json' },
189
307
  });
190
308
  }
191
309
  }
@@ -201,8 +319,8 @@ async function shutdown() {
201
319
 
202
320
  await sessionManager.closeAll();
203
321
 
204
- // Close the shared browser
205
- if (browser) {
322
+ // Close the shared browser (skip if remote — we don't own it)
323
+ if (browser && !isRemoteBrowser) {
206
324
  browser.removeAllListeners('disconnected');
207
325
  await browser.close().catch(() => {});
208
326
  }
@@ -246,24 +364,41 @@ const sessionCleanupInterval = setInterval(async () => {
246
364
  async function start() {
247
365
  const port = await findPort();
248
366
 
249
- // Launch shared Chromium
250
- browser = await chromium.launch({ headless: true });
251
-
252
- // Chromium crash flush, cleanup, exit
253
- browser.on('disconnected', () => {
254
- console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
255
- if (sessionManager) flushAllBuffers(sessionManager, true);
256
- try {
257
- const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
258
- if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
259
- fs.unlinkSync(STATE_FILE);
367
+ // Launch or connect to browser
368
+ const cdpUrl = process.env.BROWSE_CDP_URL;
369
+ if (cdpUrl) {
370
+ // Connect to remote Chrome via CDP
371
+ browser = await chromium.connectOverCDP(cdpUrl);
372
+ isRemoteBrowser = true;
373
+ console.log(`[browse] Connected to remote Chrome via CDP: ${cdpUrl}`);
374
+ } else {
375
+ // Launch local Chromium
376
+ const launchOptions: Record<string, any> = { headless: true };
377
+ const proxyServer = process.env.BROWSE_PROXY;
378
+ if (proxyServer) {
379
+ launchOptions.proxy = { server: proxyServer };
380
+ if (process.env.BROWSE_PROXY_BYPASS) {
381
+ launchOptions.proxy.bypass = process.env.BROWSE_PROXY_BYPASS;
260
382
  }
261
- } catch {}
262
- process.exit(1);
263
- });
383
+ }
384
+ browser = await chromium.launch(launchOptions);
385
+
386
+ // Chromium crash → flush, cleanup, exit (only for owned browser)
387
+ browser.on('disconnected', () => {
388
+ console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
389
+ if (sessionManager) flushAllBuffers(sessionManager, true);
390
+ try {
391
+ const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
392
+ if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
393
+ fs.unlinkSync(STATE_FILE);
394
+ }
395
+ } catch {}
396
+ process.exit(1);
397
+ });
398
+ }
264
399
 
265
400
  // Create session manager
266
- sessionManager = new SessionManager(browser);
401
+ sessionManager = new SessionManager(browser, LOCAL_DIR);
267
402
 
268
403
  const startTime = Date.now();
269
404
  const server = Bun.serve({
@@ -274,7 +409,7 @@ async function start() {
274
409
 
275
410
  // Health check — no auth required
276
411
  if (url.pathname === '/health') {
277
- const healthy = browser.isConnected();
412
+ const healthy = !isShuttingDown && browser.isConnected();
278
413
  return new Response(JSON.stringify({
279
414
  status: healthy ? 'healthy' : 'unhealthy',
280
415
  uptime: Math.floor((Date.now() - startTime) / 1000),
@@ -296,8 +431,13 @@ async function start() {
296
431
  if (url.pathname === '/command' && req.method === 'POST') {
297
432
  const body = await req.json();
298
433
  const sessionId = req.headers.get('x-browse-session') || 'default';
299
- const session = await sessionManager.getOrCreate(sessionId);
300
- return handleCommand(body, session);
434
+ const allowedDomains = req.headers.get('x-browse-allowed-domains') || undefined;
435
+ const session = await sessionManager.getOrCreate(sessionId, allowedDomains);
436
+ const opts: RequestOptions = {
437
+ jsonMode: req.headers.get('x-browse-json') === '1',
438
+ contentBoundaries: req.headers.get('x-browse-boundaries') === '1',
439
+ };
440
+ return handleCommand(body, session, opts);
301
441
  }
302
442
 
303
443
  return new Response('Not found', { status: 404 });
@@ -9,11 +9,17 @@
9
9
  import type { Browser } from 'playwright';
10
10
  import { BrowserManager } from './browser-manager';
11
11
  import { SessionBuffers } from './buffers';
12
+ import { DomainFilter } from './domain-filter';
13
+ import { sanitizeName } from './sanitize';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
12
16
 
13
17
  export interface Session {
14
18
  id: string;
15
19
  manager: BrowserManager;
16
20
  buffers: SessionBuffers;
21
+ domainFilter: DomainFilter | null;
22
+ outputDir: string;
17
23
  lastActivity: number;
18
24
  createdAt: number;
19
25
  }
@@ -21,30 +27,87 @@ export interface Session {
21
27
  export class SessionManager {
22
28
  private sessions = new Map<string, Session>();
23
29
  private browser: Browser;
30
+ private localDir: string;
24
31
 
25
- constructor(browser: Browser) {
32
+ constructor(browser: Browser, localDir: string = '/tmp') {
26
33
  this.browser = browser;
34
+ this.localDir = localDir;
27
35
  }
28
36
 
29
37
  /**
30
38
  * Get an existing session or create a new one.
31
39
  * Creating a session launches a new BrowserContext on the shared Chromium.
32
40
  */
33
- async getOrCreate(sessionId: string): Promise<Session> {
41
+ async getOrCreate(sessionId: string, allowedDomains?: string): Promise<Session> {
34
42
  let session = this.sessions.get(sessionId);
35
43
  if (session) {
36
44
  session.lastActivity = Date.now();
45
+ // Update domain filter if provided and session doesn't already have one
46
+ if (allowedDomains && !session.domainFilter) {
47
+ const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
48
+ if (domains.length > 0) {
49
+ const domainFilter = new DomainFilter(domains);
50
+ session.manager.setDomainFilter(domainFilter);
51
+ const context = session.manager.getContext();
52
+ if (context) {
53
+ await context.route('**/*', (route) => {
54
+ const url = route.request().url();
55
+ if (domainFilter.isAllowed(url)) {
56
+ route.continue();
57
+ } else {
58
+ route.abort('blockedbyclient');
59
+ }
60
+ });
61
+ const initScript = domainFilter.generateInitScript();
62
+ await context.addInitScript(initScript);
63
+ session.manager.setInitScript(initScript);
64
+ }
65
+ session.domainFilter = domainFilter;
66
+ }
67
+ }
37
68
  return session;
38
69
  }
39
70
 
71
+ // Create per-session output directory
72
+ const outputDir = path.join(this.localDir, 'sessions', sanitizeName(sessionId));
73
+ fs.mkdirSync(outputDir, { recursive: true });
74
+
40
75
  const buffers = new SessionBuffers();
41
76
  const manager = new BrowserManager(buffers);
42
77
  await manager.launchWithBrowser(this.browser);
43
78
 
79
+ // Apply domain filter if allowed domains are specified
80
+ let domainFilter: DomainFilter | null = null;
81
+ if (allowedDomains) {
82
+ const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
83
+ if (domains.length > 0) {
84
+ domainFilter = new DomainFilter(domains);
85
+ manager.setDomainFilter(domainFilter);
86
+ const context = manager.getContext();
87
+ if (context) {
88
+ // Block disallowed domains at the network level via Playwright route()
89
+ await context.route('**/*', (route) => {
90
+ const url = route.request().url();
91
+ if (domainFilter!.isAllowed(url)) {
92
+ route.continue();
93
+ } else {
94
+ route.abort('blockedbyclient');
95
+ }
96
+ });
97
+ // Block WebSocket, EventSource, sendBeacon via JS injection
98
+ const initScript = domainFilter.generateInitScript();
99
+ await context.addInitScript(initScript);
100
+ manager.setInitScript(initScript);
101
+ }
102
+ }
103
+ }
104
+
44
105
  session = {
45
106
  id: sessionId,
46
107
  manager,
47
108
  buffers,
109
+ domainFilter,
110
+ outputDir,
48
111
  lastActivity: Date.now(),
49
112
  createdAt: Date.now(),
50
113
  };
package/src/snapshot.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * Later: "click @e3" → look up Locator → locator.click()
19
19
  */
20
20
 
21
- import type { Page, Locator } from 'playwright';
21
+ import type { Page, Frame, FrameLocator, Locator } from 'playwright';
22
22
  import type { BrowserManager } from './browser-manager';
23
23
 
24
24
  // Roles considered "interactive" for the -i flag
@@ -146,13 +146,13 @@ const NATIVE_INTERACTIVE_TAGS = new Set([
146
146
  * - Elements already covered by ARIA roles
147
147
  */
148
148
  async function findCursorInteractiveElements(
149
- page: Page,
149
+ evalCtx: Page | Frame,
150
150
  scopeSelector?: string,
151
151
  ): Promise<CursorElement[]> {
152
152
  const interactiveRolesList = [...INTERACTIVE_ROLES];
153
153
  const nativeTagsList = [...NATIVE_INTERACTIVE_TAGS];
154
154
 
155
- return await page.evaluate(
155
+ return await evalCtx.evaluate(
156
156
  ({ scopeSel, interactiveRoles, nativeTags }) => {
157
157
  const root = scopeSel
158
158
  ? document.querySelector(scopeSel) || document.body
@@ -323,23 +323,27 @@ export async function handleSnapshot(
323
323
  ): Promise<string> {
324
324
  const opts = parseSnapshotArgs(args);
325
325
  const page = bm.getPage();
326
+ // When a frame is active, scope snapshot to the frame's content
327
+ const locatorRoot = bm.getLocatorRoot();
326
328
 
327
329
  // Get accessibility tree via ariaSnapshot
328
330
  let rootLocator: Locator;
329
331
  if (opts.selector) {
330
- rootLocator = page.locator(opts.selector);
332
+ rootLocator = locatorRoot.locator(opts.selector);
331
333
  const count = await rootLocator.count();
332
334
  if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
333
335
  } else {
334
- rootLocator = page.locator('body');
336
+ rootLocator = locatorRoot.locator('body');
335
337
  }
336
338
 
337
339
  const ariaText = await rootLocator.ariaSnapshot();
340
+ // Get frame context for evaluate calls (cursor-interactive scan)
341
+ const evalCtx = await bm.getFrameContext() || page;
338
342
  if (!ariaText || ariaText.trim().length === 0) {
339
343
  bm.setRefMap(new Map());
340
344
  // If -C is active, still scan for cursor-interactive even with empty ARIA
341
345
  if (opts.cursor) {
342
- const result = await appendCursorElements(page, opts, [], new Map(), 1, bm);
346
+ const result = await appendCursorElements(evalCtx, locatorRoot, opts, [], new Map(), 1, bm);
343
347
  bm.setLastSnapshot(result, args);
344
348
  return result;
345
349
  }
@@ -396,11 +400,11 @@ export async function handleSnapshot(
396
400
 
397
401
  let locator: Locator;
398
402
  if (opts.selector) {
399
- locator = page.locator(opts.selector).getByRole(node.role as any, {
403
+ locator = locatorRoot.locator(opts.selector).getByRole(node.role as any, {
400
404
  name: node.name || undefined,
401
405
  });
402
406
  } else {
403
- locator = page.getByRole(node.role as any, {
407
+ locator = locatorRoot.getByRole(node.role as any, {
404
408
  name: node.name || undefined,
405
409
  });
406
410
  }
@@ -423,7 +427,7 @@ export async function handleSnapshot(
423
427
 
424
428
  // Cursor-interactive detection: supplement ARIA tree with DOM-level scan
425
429
  if (opts.cursor) {
426
- const result = await appendCursorElements(page, opts, output, refMap, refCounter, bm);
430
+ const result = await appendCursorElements(evalCtx, locatorRoot, opts, output, refMap, refCounter, bm);
427
431
  bm.setLastSnapshot(result, args);
428
432
  return result;
429
433
  }
@@ -446,14 +450,15 @@ export async function handleSnapshot(
446
450
  * Called when -C flag is active.
447
451
  */
448
452
  async function appendCursorElements(
449
- page: Page,
453
+ evalCtx: Page | Frame,
454
+ locatorRoot: Page | FrameLocator,
450
455
  opts: SnapshotOptions,
451
456
  output: string[],
452
457
  refMap: Map<string, Locator>,
453
458
  refCounter: number,
454
459
  bm: BrowserManager,
455
460
  ): Promise<string> {
456
- const cursorElements = await findCursorInteractiveElements(page, opts.selector);
461
+ const cursorElements = await findCursorInteractiveElements(evalCtx, opts.selector);
457
462
 
458
463
  if (cursorElements.length > 0) {
459
464
  output.push('');
@@ -468,9 +473,9 @@ async function appendCursorElements(
468
473
  // share the same selector.
469
474
  let baseLocator: Locator;
470
475
  if (opts.selector) {
471
- baseLocator = page.locator(opts.selector).locator(elem.cssSelector);
476
+ baseLocator = locatorRoot.locator(opts.selector).locator(elem.cssSelector);
472
477
  } else {
473
- baseLocator = page.locator(elem.cssSelector);
478
+ baseLocator = locatorRoot.locator(elem.cssSelector);
474
479
  }
475
480
  const locator = baseLocator.nth(elem.selectorIndex);
476
481