From e6e832d2bc7ee12e4c237a107771dc31df5640fe Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 18 Jan 2026 03:56:52 +0300 Subject: [PATCH] feat: add 'get styles' command for computed styles extraction (#142) --- cli/src/commands.rs | 11 ++++++-- cli/src/output.rs | 39 +++++++++++++++++++++++++++- src/actions.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ src/protocol.ts | 6 +++++ src/types.ts | 29 +++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 74b84d5..25c2e0d 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -555,7 +555,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result Result { - const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box"]; + const VALID: &[&str] = &["text", "html", "value", "attr", "url", "title", "count", "box", "styles"]; match rest.get(0).map(|s| *s) { Some("text") => { @@ -606,13 +606,20 @@ fn parse_get(rest: &[&str], id: &str) -> Result { })?; Ok(json!({ "id": id, "action": "boundingbox", "selector": sel })) } + Some("styles") => { + let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "get styles".to_string(), + usage: "get styles ", + })?; + Ok(json!({ "id": id, "action": "styles", "selector": sel })) + } Some(sub) => Err(ParseError::UnknownSubcommand { subcommand: sub.to_string(), valid_options: VALID, }), None => Err(ParseError::MissingArguments { context: "get".to_string(), - usage: "get [args...]", + usage: "get [args...]", }), } } diff --git a/cli/src/output.rs b/cli/src/output.rs index 3a56cc1..31b98d8 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -130,6 +130,40 @@ pub fn print_response(resp: &Response, json_mode: bool) { ); return; } + // Element styles + if let Some(elements) = data.get("elements").and_then(|v| v.as_array()) { + for (i, el) in elements.iter().enumerate() { + let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); + let text = el.get("text").and_then(|v| v.as_str()).unwrap_or(""); + println!("[{}] {} \"{}\"", i, tag, text); + + if let Some(box_data) = el.get("box") { + let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0); + let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0); + let x = box_data.get("x").and_then(|v| v.as_i64()).unwrap_or(0); + let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0); + println!(" box: {}x{} at ({}, {})", w, h, x, y); + } + + if let Some(styles) = el.get("styles") { + let font_size = styles.get("fontSize").and_then(|v| v.as_str()).unwrap_or(""); + let font_weight = styles.get("fontWeight").and_then(|v| v.as_str()).unwrap_or(""); + let font_family = styles.get("fontFamily").and_then(|v| v.as_str()).unwrap_or(""); + let color = styles.get("color").and_then(|v| v.as_str()).unwrap_or(""); + let bg = styles.get("backgroundColor").and_then(|v| v.as_str()).unwrap_or(""); + let radius = styles.get("borderRadius").and_then(|v| v.as_str()).unwrap_or(""); + + println!(" font: {} {} {}", font_size, font_weight, font_family); + println!(" color: {}", color); + println!(" background: {}", bg); + if radius != "0px" { + println!(" border-radius: {}", radius); + } + } + println!(); + } + return; + } // Closed if data.get("closed").is_some() { println!("\x1b[32m✓\x1b[0m Browser closed"); @@ -676,6 +710,7 @@ Subcommands: url Get current URL count Count matching elements box Get bounding box (x, y, width, height) + styles Get computed styles of elements Global Options: --json Output as JSON @@ -690,6 +725,8 @@ Examples: agent-browser get url agent-browser get count "li.item" agent-browser get box "#header" + agent-browser get styles "button" + agent-browser get styles @e1 "##, // === Is === @@ -1209,7 +1246,7 @@ Navigation: reload Reload page Get Info: agent-browser get [selector] - text, html, value, attr , title, url, count, box + text, html, value, attr , title, url, count, box, styles Check State: agent-browser is visible, enabled, checked diff --git a/src/actions.ts b/src/actions.ts index a5fd163..1b69c05 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -50,6 +50,7 @@ import type { IsCheckedCommand, CountCommand, BoundingBoxCommand, + StylesCommand, TraceStartCommand, TraceStopCommand, HarStopCommand, @@ -117,6 +118,7 @@ import type { RecordingStopData, RecordingRestartData, InputEventData, + StylesData, } from './types.js'; import { successResponse, errorResponse } from './protocol.js'; @@ -318,6 +320,8 @@ export async function executeCommand(command: Command, browser: BrowserManager): return await handleCount(command, browser); case 'boundingbox': return await handleBoundingBox(command, browser); + case 'styles': + return await handleStyles(command, browser); case 'video_start': return await handleVideoStart(command, browser); case 'video_stop': @@ -1246,6 +1250,65 @@ async function handleBoundingBox( return successResponse(command.id, { box }); } +async function handleStyles( + command: StylesCommand, + browser: BrowserManager +): Promise> { + const page = browser.getPage(); + + // Shared extraction logic as a string to be eval'd in browser context + const extractStylesScript = `(function(el) { + const s = getComputedStyle(el); + const r = el.getBoundingClientRect(); + return { + tag: el.tagName.toLowerCase(), + text: el.innerText?.trim().slice(0, 80) || null, + box: { + x: Math.round(r.x), + y: Math.round(r.y), + width: Math.round(r.width), + height: Math.round(r.height), + }, + styles: { + fontSize: s.fontSize, + fontWeight: s.fontWeight, + fontFamily: s.fontFamily.split(',')[0].trim().replace(/"/g, ''), + color: s.color, + backgroundColor: s.backgroundColor, + borderRadius: s.borderRadius, + border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null, + boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null, + padding: s.padding, + }, + }; + })`; + + // Check if it's a ref - single element + if (browser.isRef(command.selector)) { + const locator = browser.getLocator(command.selector); + const element = (await locator.evaluate( + (el, script) => { + const fn = eval(script); + return fn(el); + }, + extractStylesScript + )) as StylesData['elements'][0]; + return successResponse(command.id, { elements: [element] }); + } + + // CSS selector - can match multiple elements + const elements = (await page.$$eval( + command.selector, + (els, script) => { + const fn = eval(script); + return els.map((el) => fn(el)); + }, + extractStylesScript + )) as StylesData['elements']; + + return successResponse(command.id, { elements }); +} + // Advanced handlers async function handleVideoStart( diff --git a/src/protocol.ts b/src/protocol.ts index 4f91f0a..a8500b4 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -306,6 +306,11 @@ const boundingBoxSchema = baseCommandSchema.extend({ selector: z.string().min(1), }); +const stylesSchema = baseCommandSchema.extend({ + action: z.literal('styles'), + selector: z.string().min(1), +}); + const videoStartSchema = baseCommandSchema.extend({ action: z.literal('video_start'), path: z.string().min(1), @@ -820,6 +825,7 @@ const commandSchema = z.discriminatedUnion('action', [ isCheckedSchema, countSchema, boundingBoxSchema, + stylesSchema, videoStartSchema, videoStopSchema, recordingStartSchema, diff --git a/src/types.ts b/src/types.ts index d0cc565..2eb09a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -316,6 +316,12 @@ export interface BoundingBoxCommand extends BaseCommand { selector: string; } +// Computed styles +export interface StylesCommand extends BaseCommand { + action: 'styles'; + selector: string; +} + // More semantic locators export interface GetByAltTextCommand extends BaseCommand { action: 'getbyalttext'; @@ -857,6 +863,7 @@ export type Command = | IsCheckedCommand | CountCommand | BoundingBoxCommand + | StylesCommand | VideoStartCommand | VideoStopCommand | RecordingStartCommand @@ -1017,6 +1024,28 @@ export interface InputEventData { injected: boolean; } +// Element styles data +export interface ElementStyleInfo { + tag: string; + text: string | null; + box: { x: number; y: number; width: number; height: number }; + styles: { + fontSize: string; + fontWeight: string; + fontFamily: string; + color: string; + backgroundColor: string; + borderRadius: string; + border: string | null; + boxShadow: string | null; + padding: string; + }; +} + +export interface StylesData { + elements: ElementStyleInfo[]; +} + // Browser state export interface BrowserState { browser: Browser | null;