feat: add 'get styles' command for computed styles extraction (#142)
This commit is contained in:
+9
-2
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user