Skip to content

Commit 8eeaf90

Browse files
committed
feat: promote eval to top-level command, add --frame option for iframe targeting
- Move `bdg dom eval` to `bdg eval` as a top-level command - Add FrameOptions interface and --frame flag for DOM commands to target iframes - Add error logging utility - Update all references, formatters, messages, and tests
1 parent e2b0285 commit 8eeaf90

25 files changed

Lines changed: 329 additions & 129 deletions

.claude/skills/bdg/SKILL.md

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ description: Use bdg CLI for browser automation via Chrome DevTools Protocol. Pr
55

66
# bdg - Browser Automation CLI
77

8+
**Always use `--json`** for programmatic output - human output truncates URLs and omits details.
9+
810
## Quick Start
911

1012
```bash
@@ -65,6 +67,32 @@ bdg dom query "button" # Shows [0], [1], [2]...
6567
bdg dom click "button" --index 0 # Click first match
6668
```
6769

70+
## Iframe Support
71+
72+
Use `--frame` to query/interact with elements inside iframes (same-origin only):
73+
74+
```bash
75+
# Find iframe first
76+
bdg dom query "iframe" --json # List iframes by tag
77+
bdg dom query 'iframe[title="MyApp"]' --json # Find by title attribute
78+
79+
# Query inside iframe
80+
bdg dom query "[aria-label='Submit']" --frame 'iframe[title="MyApp"]' --json
81+
82+
# Interact with elements inside iframe
83+
bdg dom click "button.submit" --frame 'iframe[title="MyApp"]'
84+
bdg dom fill "input[name='email']" "test@example.com" --frame 'iframe[title="MyApp"]'
85+
bdg dom pressKey "input" Enter --frame 'iframe[title="MyApp"]'
86+
bdg dom get "form" --raw --frame 'iframe[title="MyApp"]'
87+
```
88+
89+
**Notes:**
90+
- Only works with same-origin iframes (cross-origin iframes are blocked by browser security)
91+
- Use `bdg eval` for advanced iframe access:
92+
```bash
93+
bdg eval 'document.querySelector("iframe").contentDocument.querySelector("button")?.outerHTML'
94+
```
95+
6896
## Form Interaction
6997

7098
```bash
@@ -82,12 +110,32 @@ Options: `--no-wait`, `--wait-navigation`, `--wait-network <ms>`, `--index <n>`
82110
## DOM Inspection
83111

84112
```bash
85-
bdg dom query "selector" # Find elements matching selector
86-
bdg dom get "selector" # Get semantic a11y info (token-efficient)
87-
bdg dom get "selector" --raw # Get full HTML
88-
bdg dom eval "js expression" # Run JavaScript (handles DOM elements)
113+
bdg dom query "selector" --json # Find elements (returns nodeIds, classes, text)
114+
bdg dom get "selector" --json # Get semantic a11y info (token-efficient)
115+
bdg dom get "selector" --raw # Get full HTML
116+
bdg eval "js expression" # Run JavaScript (handles DOM elements)
89117
```
90118

119+
## Network Requests
120+
121+
Network capture is **automatic** from session start. **Always use `--json`** for full data (URLs are truncated in human output).
122+
123+
```bash
124+
bdg network list --json # List requests (full URLs, headers, bodies)
125+
bdg network list --json --last 0 # All requests
126+
bdg network list --json --preset errors # 4xx/5xx responses
127+
bdg network list --json --preset api # XHR/Fetch only
128+
bdg network list --json --filter "domain:api.* status-code:>=400"
129+
bdg network har /tmp/trace.har # Export as HAR file
130+
bdg network getCookies --json # List cookies
131+
bdg network headers --json # Main document headers
132+
bdg network headers --json <request-id> # Specific request headers
133+
```
134+
135+
Filter syntax: `status-code:>=400`, `domain:api.*`, `method:POST`, `mime-type:application/json`, `larger-than:100KB`, `!domain:cdn.*` (negate)
136+
137+
Presets: `errors`, `api`, `large`, `cached`, `documents`, `media`, `scripts`, `pending`
138+
91139
## CDP Access
92140

93141
Direct access to Chrome DevTools Protocol:

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { registerConsoleCommand } from '@/commands/console.js';
66
import { registerDetailsCommand } from '@/commands/details.js';
77
import { registerFormInteractionCommands } from '@/commands/dom/formInteraction.js';
88
import { registerDomCommands } from '@/commands/dom/index.js';
9+
import { registerEvalCommand } from '@/commands/eval.js';
910
import { registerNetworkCommands } from '@/commands/network/index.js';
1011
import { registerPeekCommand } from '@/commands/peek.js';
1112
import { registerStartCommands } from '@/commands/start.js';
@@ -45,6 +46,7 @@ export const commandRegistry: CommandRegistrar[] = [
4546
registerDetailsCommand,
4647
registerDomCommands,
4748
registerFormInteractionCommands,
49+
registerEvalCommand,
4850

4951
addCommandGroup('CDP Commands:'),
5052
registerCdpCommand,

src/commands/cdp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { findSimilar } from '@/utils/suggestions.js';
2525
const DOMAIN_NOTES: Record<string, string> = {
2626
Audits:
2727
'Event-based domain. Results arrive via events (e.g., Audits.issueAdded), not method responses. ' +
28-
"For contrast checking, use: bdg dom eval 'getComputedStyle(el).color'",
28+
"For contrast checking, use: bdg eval 'getComputedStyle(el).color'",
2929
Overlay:
3030
'Visual debugging domain. Methods like highlightNode show overlays but return empty. ' +
3131
'Use Overlay.hideHighlight to clear.',
@@ -44,7 +44,7 @@ const DOMAIN_NOTES: Record<string, string> = {
4444
const METHOD_NOTES: Record<string, string> = {
4545
'Audits.checkContrast':
4646
'This method triggers contrast analysis but results are sent via Audits.issueAdded events. ' +
47-
'Alternative: bdg dom eval with getComputedStyle() for direct contrast checking.',
47+
'Alternative: bdg eval with getComputedStyle() for direct contrast checking.',
4848
'Audits.enable': 'Enables the Audits domain. Issues will arrive via Audits.issueAdded events.',
4949
'Overlay.highlightNode':
5050
'Highlights a node visually. Returns empty on success. Use Overlay.hideHighlight to clear.',

src/commands/dom/formFillHelpers.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ function formatScriptExecutionError(
9090
? [
9191
`1. Verify element exists: bdg dom query "${selector}"`,
9292
'2. Check element is visible and not disabled',
93-
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').value = 'your-value'"`,
93+
`3. Try direct eval: bdg eval "document.querySelector('${escapeSelectorForJS(selector)}').value = 'your-value'"`,
9494
]
9595
: [
9696
`1. Verify element exists: bdg dom query "${selector}"`,
9797
'2. Check element is visible and clickable',
98-
`3. Try direct eval: bdg dom eval "document.querySelector('${escapeSelectorForJS(selector)}').click()"`,
98+
`3. Try direct eval: bdg eval "document.querySelector('${escapeSelectorForJS(selector)}').click()"`,
9999
];
100100

101101
lines.push('');
@@ -133,6 +133,7 @@ export async function fillElement(
133133
const scriptOptions = {
134134
blur: options.blur ?? true,
135135
index: options.index,
136+
frame: options.frame,
136137
};
137138

138139
const expression = `(${REACT_FILL_SCRIPT})('${escapeSelectorForJS(selector)}', '${escapeValueForJS(value)}', ${JSON.stringify(scriptOptions)})`;
@@ -201,10 +202,11 @@ export async function fillElement(
201202
export async function clickElement(
202203
cdp: CDPConnection,
203204
selector: string,
204-
options: { index?: number } = {}
205+
options: { index?: number; frame?: string } = {}
205206
): Promise<ClickResult> {
206207
const indexArg = options.index ?? 'null';
207-
const expression = `(${CLICK_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg})`;
208+
const frameArg = options.frame ? `'${escapeSelectorForJS(options.frame)}'` : 'null';
209+
const expression = `(${CLICK_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${indexArg}, ${frameArg})`;
208210

209211
try {
210212
const response = await cdp.send('Runtime.evaluate', {
@@ -346,6 +348,8 @@ export interface PressKeyOptions {
346348
index?: number;
347349
/** Number of times to press the key (default: 1) */
348350
times?: number;
351+
/** CSS selector for iframe to query within */
352+
frame?: string;
349353
/** Comma-separated modifier keys (shift, ctrl, alt, meta) */
350354
modifiers?: string;
351355
}
@@ -370,9 +374,9 @@ export interface PressKeyResult {
370374
* @returns Object with success status and element info
371375
*/
372376
const FOCUS_ELEMENT_SCRIPT = `
373-
(function(selector) {
377+
(function(selector, frameSelector) {
374378
${QUERY_ELEMENTS_HELPER}
375-
const elements = __bdgQueryElements(selector);
379+
const elements = __bdgQueryElements(selector, frameSelector || null);
376380
if (elements.length === 0) {
377381
return { success: false, error: 'No nodes found matching selector: ' + selector };
378382
}
@@ -429,7 +433,8 @@ export async function pressKeyElement(
429433

430434
const times = options.times ?? 1;
431435
const modifierFlags = parseModifiers(options.modifiers);
432-
const focusExpression = `(${FOCUS_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}')`;
436+
const frameArg = options.frame ? `'${escapeSelectorForJS(options.frame)}'` : 'null';
437+
const focusExpression = `(${FOCUS_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${frameArg})`;
433438

434439
try {
435440
const focusResponse = await cdp.send('Runtime.evaluate', {
@@ -597,6 +602,8 @@ export interface ScrollOptions {
597602
bottom?: boolean;
598603
/** Element index if selector matches multiple (0-based) */
599604
index?: number;
605+
/** CSS selector for iframe to query within */
606+
frame?: string;
600607
}
601608

602609
/**
@@ -631,9 +638,9 @@ export interface ScrollResult {
631638
* Script to scroll an element into view.
632639
*/
633640
const SCROLL_TO_ELEMENT_SCRIPT = `
634-
(function(selector) {
641+
(function(selector, frameSelector) {
635642
${QUERY_ELEMENTS_HELPER}
636-
const elements = __bdgQueryElements(selector);
643+
const elements = __bdgQueryElements(selector, frameSelector || null);
637644
if (elements.length === 0) {
638645
return { success: false, error: 'No nodes found matching selector: ' + selector };
639646
}
@@ -747,7 +754,8 @@ export async function scrollPage(
747754
): Promise<ScrollResult> {
748755
try {
749756
if (selector) {
750-
const expression = `(${SCROLL_TO_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}')`;
757+
const frameArg = options.frame ? `'${escapeSelectorForJS(options.frame)}'` : 'null';
758+
const expression = `(${SCROLL_TO_ELEMENT_SCRIPT})('${escapeSelectorForJS(selector)}', ${frameArg})`;
751759

752760
const response = await cdp.send('Runtime.evaluate', {
753761
expression,

src/commands/dom/formInteraction.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export function registerFormInteractionCommands(program: Command): void {
113113
.option('--index <n>', 'Element index if selector matches multiple (0-based)', parseInt)
114114
.option('--no-blur', 'Do not blur after filling (keeps focus on element)')
115115
.option('--no-wait', 'Skip waiting for network stability after fill')
116+
.option('--frame <selector>', 'Query inside iframe matching selector')
116117
.addOption(jsonOption())
117118
.action(async (selector: string, value: string, options: FillCommandOptions) => {
118119
await runCommand(
@@ -121,7 +122,8 @@ export function registerFormInteractionCommands(program: Command): void {
121122
const fillOptions = filterDefined({
122123
index: options.index,
123124
blur: options.blur,
124-
}) as { index?: number; blur?: boolean };
125+
frame: options.frame,
126+
}) as { index?: number; blur?: boolean; frame?: string };
125127

126128
const result = await fillElement(cdp, selector, value, fillOptions);
127129

@@ -158,14 +160,16 @@ export function registerFormInteractionCommands(program: Command): void {
158160
.argument('<selector>', 'CSS/Playwright selector (e.g., "button", ":text(\'Submit\')")')
159161
.option('--index <n>', 'Element index if selector matches multiple (0-based)', parseInt)
160162
.option('--no-wait', 'Skip waiting for network stability after click')
163+
.option('--frame <selector>', 'Query inside iframe matching selector')
161164
.addOption(jsonOption())
162165
.action(async (selector: string, options: ClickCommandOptions) => {
163166
await runCommand(
164167
async () => {
165168
return await withCDPConnection(async (cdp) => {
166169
const clickOptions = filterDefined({
167170
index: options.index,
168-
}) as { index?: number };
171+
frame: options.frame,
172+
}) as { index?: number; frame?: string };
169173

170174
const result = await clickElement(cdp, selector, clickOptions);
171175

@@ -255,6 +259,7 @@ export function registerFormInteractionCommands(program: Command): void {
255259
.option('--times <n>', 'Press key multiple times (default: 1)', parseInt)
256260
.option('--modifiers <mods>', 'Modifier keys: shift,ctrl,alt,meta (comma-separated)')
257261
.option('--no-wait', 'Skip waiting for network stability after key press')
262+
.option('--frame <selector>', 'Query inside iframe matching selector')
258263
.addOption(jsonOption())
259264
.action(async (selector: string, key: string, options: PressKeyCommandOptions) => {
260265
await runCommand(
@@ -264,7 +269,8 @@ export function registerFormInteractionCommands(program: Command): void {
264269
index: options.index,
265270
times: options.times,
266271
modifiers: options.modifiers,
267-
}) as { index?: number; times?: number; modifiers?: string };
272+
frame: options.frame,
273+
}) as { index?: number; times?: number; modifiers?: string; frame?: string };
268274

269275
const result = await pressKeyElement(cdp, selector, key, pressKeyOptions);
270276

@@ -306,6 +312,7 @@ export function registerFormInteractionCommands(program: Command): void {
306312
.option('--top', 'Scroll to page top')
307313
.option('--bottom', 'Scroll to page bottom')
308314
.option('--no-wait', 'Skip waiting for lazy-loaded content after scroll')
315+
.option('--frame <selector>', 'Query inside iframe matching selector')
309316
.addOption(jsonOption())
310317
.action(async (selector: string | undefined, options: ScrollCommandOptions) => {
311318
await runCommand(
@@ -369,6 +376,7 @@ export function registerFormInteractionCommands(program: Command): void {
369376
right: options.right,
370377
top: options.top,
371378
bottom: options.bottom,
379+
frame: options.frame,
372380
}) as {
373381
index?: number;
374382
down?: number;
@@ -377,6 +385,7 @@ export function registerFormInteractionCommands(program: Command): void {
377385
right?: number;
378386
top?: boolean;
379387
bottom?: boolean;
388+
frame?: string;
380389
};
381390

382391
const result = await scrollPage(cdp, selector, scrollOptions);

src/commands/dom/helpers.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,24 @@ const STABILITY_CHECK_INTERVAL_MS = 50;
6868
/**
6969
* JavaScript helper for Playwright-style selector support.
7070
* Injected into browser context for all DOM queries.
71+
*
72+
* @param selector - CSS/Playwright selector
73+
* @param frameSelector - Optional iframe selector to query within
7174
*/
7275
const QUERY_ELEMENTS_JS = `
73-
function __bdgQueryElements(selector) {
76+
function __bdgQueryElements(selector, frameSelector) {
77+
// Get the document to query (main document or iframe contentDocument)
78+
let doc = document;
79+
if (frameSelector) {
80+
const frame = document.querySelector(frameSelector);
81+
if (!frame) return [];
82+
doc = frame.contentDocument;
83+
if (!doc) return []; // Cross-origin iframe
84+
}
85+
7486
// Quick check for Playwright pseudo-classes
7587
if (!/:(?:has-text|text-is|text|visible)\\s*(?:\\(|$)/.test(selector)) {
76-
return [...document.querySelectorAll(selector)];
88+
return [...doc.querySelectorAll(selector)];
7789
}
7890
7991
// Parse out Playwright pseudo-classes
@@ -108,9 +120,10 @@ function __bdgQueryElements(selector) {
108120
cssSelector = cssSelector.replace(/\\s+/g, ' ').trim() || '*';
109121
110122
// Query with CSS selector
111-
let elements = [...document.querySelectorAll(cssSelector)];
123+
let elements = [...doc.querySelectorAll(cssSelector)];
112124
113-
// Apply filters for each pseudo-class
125+
// Apply filters for each pseudo-class (use main window for getComputedStyle)
126+
const win = doc.defaultView || window;
114127
for (const pseudo of pseudoMatches) {
115128
elements = elements.filter(el => {
116129
switch (pseudo.type) {
@@ -133,7 +146,7 @@ function __bdgQueryElements(selector) {
133146
return el.textContent?.replace(/\\s+/g, ' ').trim() === pseudo.arg;
134147
}
135148
case 'visible': {
136-
const style = window.getComputedStyle(el);
149+
const style = win.getComputedStyle(el);
137150
if (style.display === 'none') return false;
138151
if (style.visibility === 'hidden') return false;
139152
if (style.opacity === '0') return false;
@@ -325,16 +338,19 @@ async function restoreScrollPosition(position: ScrollPosition): Promise<void> {
325338
* Supports standard CSS selectors and Playwright-style pseudo-classes.
326339
*
327340
* @param selector - CSS/Playwright selector
341+
* @param frame - Optional iframe selector to query within
328342
* @returns Query result with matched nodes
329343
*/
330-
export async function queryDOMElements(selector: string): Promise<DomQueryResult> {
344+
export async function queryDOMElements(selector: string, frame?: string): Promise<DomQueryResult> {
331345
const escapedSelector = escapeSelector(selector);
346+
const escapedFrame = frame ? escapeSelector(frame) : '';
347+
const frameArg = frame ? `'${escapedFrame}'` : 'null';
332348

333349
const result = await callCDP('Runtime.evaluate', {
334350
expression: `
335351
${QUERY_ELEMENTS_JS}
336352
(() => {
337-
const elements = __bdgQueryElements('${escapedSelector}');
353+
const elements = __bdgQueryElements('${escapedSelector}', ${frameArg});
338354
return elements.map((el, index) => {
339355
const tag = el.tagName?.toLowerCase() || '';
340356
const classes = el.className?.split?.(/\\s+/).filter(c => c.length > 0) || [];
@@ -383,12 +399,14 @@ export async function getDOMElements(options: DomGetOptions): Promise<DomGetResu
383399
}
384400

385401
const escapedSelector = escapeSelector(options.selector);
402+
const escapedFrame = options.frame ? escapeSelector(options.frame) : '';
403+
const frameArg = options.frame ? `'${escapedFrame}'` : 'null';
386404

387405
const result = await callCDP('Runtime.evaluate', {
388406
expression: `
389407
${QUERY_ELEMENTS_JS}
390408
(() => {
391-
const elements = __bdgQueryElements('${escapedSelector}');
409+
const elements = __bdgQueryElements('${escapedSelector}', ${frameArg});
392410
${options.all ? '' : 'elements.splice(1);'} // Keep only first unless --all
393411
return elements.map(el => {
394412
const tag = el.tagName?.toLowerCase() || '';

0 commit comments

Comments
 (0)