feat: add 'get styles' command for computed styles extraction (#142)

This commit is contained in:
Danila Poyarkov
2026-01-17 18:56:52 -06:00
committed by GitHub
parent b19ca760aa
commit e6e832d2bc
5 changed files with 145 additions and 3 deletions
+9 -2
View File
@@ -555,7 +555,7 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
} }
fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> { fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
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) { match rest.get(0).map(|s| *s) {
Some("text") => { Some("text") => {
@@ -606,13 +606,20 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
})?; })?;
Ok(json!({ "id": id, "action": "boundingbox", "selector": sel })) 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 <selector>",
})?;
Ok(json!({ "id": id, "action": "styles", "selector": sel }))
}
Some(sub) => Err(ParseError::UnknownSubcommand { Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(), subcommand: sub.to_string(),
valid_options: VALID, valid_options: VALID,
}), }),
None => Err(ParseError::MissingArguments { None => Err(ParseError::MissingArguments {
context: "get".to_string(), context: "get".to_string(),
usage: "get <text|html|value|attr|url|title|count|box> [args...]", usage: "get <text|html|value|attr|url|title|count|box|styles> [args...]",
}), }),
} }
} }
+38 -1
View File
@@ -130,6 +130,40 @@ pub fn print_response(resp: &Response, json_mode: bool) {
); );
return; 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 // Closed
if data.get("closed").is_some() { if data.get("closed").is_some() {
println!("\x1b[32m✓\x1b[0m Browser closed"); println!("\x1b[32m✓\x1b[0m Browser closed");
@@ -676,6 +710,7 @@ Subcommands:
url Get current URL url Get current URL
count <selector> Count matching elements count <selector> Count matching elements
box <selector> Get bounding box (x, y, width, height) box <selector> Get bounding box (x, y, width, height)
styles <selector> Get computed styles of elements
Global Options: Global Options:
--json Output as JSON --json Output as JSON
@@ -690,6 +725,8 @@ Examples:
agent-browser get url agent-browser get url
agent-browser get count "li.item" agent-browser get count "li.item"
agent-browser get box "#header" agent-browser get box "#header"
agent-browser get styles "button"
agent-browser get styles @e1
"##, "##,
// === Is === // === Is ===
@@ -1209,7 +1246,7 @@ Navigation:
reload Reload page reload Reload page
Get Info: agent-browser get <what> [selector] Get Info: agent-browser get <what> [selector]
text, html, value, attr <name>, title, url, count, box text, html, value, attr <name>, title, url, count, box, styles
Check State: agent-browser is <what> <selector> Check State: agent-browser is <what> <selector>
visible, enabled, checked visible, enabled, checked
+63
View File
@@ -50,6 +50,7 @@ import type {
IsCheckedCommand, IsCheckedCommand,
CountCommand, CountCommand,
BoundingBoxCommand, BoundingBoxCommand,
StylesCommand,
TraceStartCommand, TraceStartCommand,
TraceStopCommand, TraceStopCommand,
HarStopCommand, HarStopCommand,
@@ -117,6 +118,7 @@ import type {
RecordingStopData, RecordingStopData,
RecordingRestartData, RecordingRestartData,
InputEventData, InputEventData,
StylesData,
} from './types.js'; } from './types.js';
import { successResponse, errorResponse } from './protocol.js'; import { successResponse, errorResponse } from './protocol.js';
@@ -318,6 +320,8 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleCount(command, browser); return await handleCount(command, browser);
case 'boundingbox': case 'boundingbox':
return await handleBoundingBox(command, browser); return await handleBoundingBox(command, browser);
case 'styles':
return await handleStyles(command, browser);
case 'video_start': case 'video_start':
return await handleVideoStart(command, browser); return await handleVideoStart(command, browser);
case 'video_stop': case 'video_stop':
@@ -1246,6 +1250,65 @@ async function handleBoundingBox(
return successResponse(command.id, { box }); return successResponse(command.id, { box });
} }
async function handleStyles(
command: StylesCommand,
browser: BrowserManager
): Promise<Response<StylesData>> {
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 // Advanced handlers
async function handleVideoStart( async function handleVideoStart(
+6
View File
@@ -306,6 +306,11 @@ const boundingBoxSchema = baseCommandSchema.extend({
selector: z.string().min(1), selector: z.string().min(1),
}); });
const stylesSchema = baseCommandSchema.extend({
action: z.literal('styles'),
selector: z.string().min(1),
});
const videoStartSchema = baseCommandSchema.extend({ const videoStartSchema = baseCommandSchema.extend({
action: z.literal('video_start'), action: z.literal('video_start'),
path: z.string().min(1), path: z.string().min(1),
@@ -820,6 +825,7 @@ const commandSchema = z.discriminatedUnion('action', [
isCheckedSchema, isCheckedSchema,
countSchema, countSchema,
boundingBoxSchema, boundingBoxSchema,
stylesSchema,
videoStartSchema, videoStartSchema,
videoStopSchema, videoStopSchema,
recordingStartSchema, recordingStartSchema,
+29
View File
@@ -316,6 +316,12 @@ export interface BoundingBoxCommand extends BaseCommand {
selector: string; selector: string;
} }
// Computed styles
export interface StylesCommand extends BaseCommand {
action: 'styles';
selector: string;
}
// More semantic locators // More semantic locators
export interface GetByAltTextCommand extends BaseCommand { export interface GetByAltTextCommand extends BaseCommand {
action: 'getbyalttext'; action: 'getbyalttext';
@@ -857,6 +863,7 @@ export type Command =
| IsCheckedCommand | IsCheckedCommand
| CountCommand | CountCommand
| BoundingBoxCommand | BoundingBoxCommand
| StylesCommand
| VideoStartCommand | VideoStartCommand
| VideoStopCommand | VideoStopCommand
| RecordingStartCommand | RecordingStartCommand
@@ -1017,6 +1024,28 @@ export interface InputEventData {
injected: boolean; 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 // Browser state
export interface BrowserState { export interface BrowserState {
browser: Browser | null; browser: Browser | null;