Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/headless-computer-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ headless --session agent-qa screenshot --full-page --output before.png
headless --session agent-qa screenshot --format jpg --output before.jpg
headless --session agent-qa screenshot --every-viewport --output page-scroll
headless --session agent-qa screenshot --by-section --output page-sections
headless --session agent-qa screenshot --by-region @r4 --output focused-region
headless --session agent-qa click --role button --name Continue
headless --session agent-qa wait --url /next --text "Designer details" --settled
headless --session agent-qa screenshot --full-page --output after.png
Expand Down
5 changes: 3 additions & 2 deletions .agents/skills/headless-computer-use/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ headless --session NAME screenshot REF --output element.png
headless --session NAME screenshot --role button --name Continue --output button.png
headless --session NAME screenshot --every-viewport --format jpg --output page-scroll
headless --session NAME screenshot --by-section --output page-sections
headless --session NAME screenshot --by-region @r4 --output focused-region
headless --session NAME record start --fps 10 --format mp4 --quality balanced
headless --session NAME record status
headless --session NAME record stop --output flow.mp4
Expand Down Expand Up @@ -117,8 +118,8 @@ For human-reviewable evidence:

1. Start recording before the action under test.
2. Use `tour --full-page --pace 500` when scrolling is part of the evidence.
3. Capture key-state screenshots, plus `--every-viewport` or `--by-section`
screenshots for long scrollable pages.
3. Capture key-state screenshots, plus `--every-viewport`, `--by-section`, or
an outline-scoped `--by-region @rN` series for broader visual evidence.
4. Stop recording on both success and failure paths.
5. List the artifact metadata.
6. Verify the copied file independently when tools are available:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Two versions travel independently, on purpose:

## [Unreleased]

- Add bounded cross-engine `screenshot --by-region @rN` series with exact
border-box crops, document and geometry revalidation, atomic artifacts, and
enforced scroll restoration.

### Added

- macOS Command-, opens one Settings window built from the typed registry and
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ headless --session qa screenshot --format jpg --output next-page.jpg --clipboard
headless --session qa screenshot --format pdf --full-page --output next-page.pdf
headless --session qa screenshot --every-viewport --output dashboard-scroll
headless --session qa screenshot --by-section --output dashboard-sections
headless --session qa screenshot --by-region @r4 --output checkout-region
headless --session qa qa report
headless --session qa console list --level error
headless --session qa network list --failed
Expand Down Expand Up @@ -143,6 +144,17 @@ jpg` for JPEG series; PDF requires `--full-page` and is not a series format. The
output prefix creates numbered artifacts such as `dashboard-scroll-001.png` or
`dashboard-scroll-001.jpg`.

After `inspect --context outline`, use `screenshot --by-region @rN --output
PREFIX` to capture only that region element's rendered border box as bounded
vertical slices. The region must fit horizontally in the viewport and cannot
exceed the portable 4096 CSS-pixel capture width. Nested
scroll-container content outside the rendered box is not expanded. Unknown,
expired, hidden, clipped, or changing regions fail without keeping partial
artifacts. A series with `truncated: true` contains the first 79 slices and the
final slice, so its omitted middle is explicit rather than presented as full
coverage. Scroll restoration is enforced while the original document remains
active.

When an action fails, use the on-demand diagnostic commands instead of an
interactive DevTools UI: `console list`, `network list|get`, `styles get`,
`cookies list`, and `storage list`. Cookie and storage values stay redacted
Expand Down
20 changes: 18 additions & 2 deletions apps/headless-rs/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ impl CommandRequest {
fn validate_screenshot(&self) -> Result<(), ValidationError> {
self.allow(&[
"fullPage", "target", "role", "name", "output", "series", "outputPrefix", "format",
"clipboard",
"clipboard", "region",
])?;
self.boolean("fullPage")?;
self.boolean("clipboard")?;
Expand All @@ -546,12 +546,28 @@ impl CommandRequest {
self.parameter("target").is_some() || self.parameter("role").is_some() || self.parameter("name").is_some();
let series = self.string("series", false, 32)?;
if let Some(series) = &series {
if !["viewport", "section"].contains(&series.as_str()) {
if !["viewport", "section", "region"].contains(&series.as_str()) {
return Err(ValidationError::InvalidParameter(
"Invalid screenshot series".to_string(),
));
}
}
let region = self.string("region", false, 16)?;
if let Some(region) = &region {
let digits = region.strip_prefix("@r").map(|rest| {
!rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit())
});
if digits != Some(true) {
return Err(ValidationError::InvalidParameter(
"Invalid screenshot region reference".to_string(),
));
}
}
if (series.as_deref() == Some("region")) != region.is_some() {
return Err(ValidationError::InvalidParameter(
"Region screenshot series requires exactly one region reference".to_string(),
));
}
let format = screenshot_format(
self.string("format", false, 16)?.as_deref(),
self.parameter("output").and_then(|v| v.string_value()),
Expand Down
19 changes: 19 additions & 0 deletions apps/headless-rs/tests/protocol_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,25 @@ fn command_parameter_validation() {
params(&[("format", json!("pdf")), ("fullPage", json!(true)), ("output", json!("page.pdf"))]),
);
request.validate().unwrap();
let request = valid_request(
CommandName::Screenshot,
params(&[
("series", json!("region")),
("region", json!("@r4")),
("outputPrefix", json!("checkout")),
]),
);
request.validate().unwrap();
let request = valid_request(
CommandName::Screenshot,
params(&[("series", json!("region"))]),
);
assert!(request.validate().is_err());
let request = valid_request(
CommandName::Screenshot,
params(&[("series", json!("viewport")), ("region", json!("@r4"))]),
);
assert!(request.validate().is_err());

// visual compare only accepts private PNG artifacts
let request = valid_request(
Expand Down
137 changes: 119 additions & 18 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ extension BrowserWindowController {
format: ScreenshotFormat,
copyToClipboard: Bool
) throws -> ScreenshotArtifactData {
let image = try agentScreenshotImage(parameters: parameters)
guard let data = encodeScreenshot(image, format: format) else {
let capture = try agentScreenshotImage(parameters: parameters)
guard let data = encodeScreenshot(
capture.image, format: format, pixelSize: capture.regionPixelSize
) else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
if copyToClipboard {
Expand All @@ -218,16 +220,53 @@ extension BrowserWindowController {
}
onMain {
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([image])
NSPasteboard.general.writeObjects([capture.image])
}
}
return ScreenshotArtifactData(data: data, clipboardCopied: copyToClipboard)
}

private func agentScreenshotImage(parameters: [String: JSONValue]) throws -> NSImage {
private func agentScreenshotImage(
parameters: [String: JSONValue]
) throws -> (image: NSImage, regionPixelSize: CGSize?) {
var requestedRect: CGRect?
var regionSliceArguments: [String: Any]?
let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil
if hasTarget {
let hasRegionSlice = ["_region", "_document", "_geometry", "_sliceTop", "_sliceHeight"]
.contains { parameters[$0] != nil }
if hasRegionSlice {
guard let reference = parameters["_region"]?.stringValue,
let document = parameters["_document"]?.stringValue,
case .object(let geometry)? = parameters["_geometry"],
let x = geometry["x"]?.numberValue,
let y = geometry["y"]?.numberValue,
let width = geometry["width"]?.numberValue,
let height = geometry["height"]?.numberValue,
let sliceTop = parameters["_sliceTop"]?.numberValue,
let sliceHeight = parameters["_sliceHeight"]?.numberValue else {
throw ScreenshotSeriesError.invalidPlan
}
var args: [String: Any] = [
"region": reference, "document": document,
"geometry": ["x": x, "y": y, "width": width, "height": height],
"sliceTop": sliceTop, "sliceHeight": sliceHeight,
]
let value = try callAgent(
"return globalThis.__headlessAgent.regionSlice(args);",
arguments: ["args": args]
)
guard case .object(let outer) = value, case .object(let rect)? = outer["viewport"] else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
requestedRect = try screenshotRect(rect)
if let requestedRect {
args["viewport"] = [
"x": requestedRect.origin.x, "y": requestedRect.origin.y,
"width": requestedRect.width, "height": requestedRect.height,
]
}
regionSliceArguments = args
} else if hasTarget {
let args = try browserTargetArguments(parameters)
let value = try callAgent(
"return globalThis.__headlessAgent.rectangle(args);", arguments: ["args": args]
Expand Down Expand Up @@ -279,25 +318,72 @@ extension BrowserWindowController {
guard let image = try result?.get() else {
throw HostError(code: .operationFailed, message: "Browser returned an invalid agent result")
}
return image
if let regionSliceArguments {
_ = try callAgent(
"return globalThis.__headlessAgent.regionSlice(args);",
arguments: ["args": regionSliceArguments]
)
}
return (image, regionSliceArguments == nil ? nil : requestedRect?.size)
}

private func encodeScreenshot(_ image: NSImage, format: ScreenshotFormat) -> Data? {
private func encodeScreenshot(
_ image: NSImage, format: ScreenshotFormat, pixelSize: CGSize? = nil
) -> Data? {
switch format {
case .png:
return bitmapData(for: image, type: .png, properties: [:])
return bitmapData(for: image, pixelSize: pixelSize, type: .png, properties: [:])
case .jpeg:
return bitmapData(for: image, type: .jpeg, properties: [.compressionFactor: 0.88])
return bitmapData(
for: image, pixelSize: pixelSize,
type: .jpeg, properties: [.compressionFactor: 0.88]
)
case .pdf:
return pdfData(for: image)
}
}

private func bitmapData(
for image: NSImage,
pixelSize: CGSize?,
type: NSBitmapImageRep.FileType,
properties: [NSBitmapImageRep.PropertyKey: Any]
) -> Data? {
if let pixelSize {
let width = Int(ceil(pixelSize.width))
let height = Int(ceil(pixelSize.height))
guard width > 0, height > 0,
width <= Int(ProtocolBounds.screenshotDimension),
height <= Int(ProtocolBounds.screenshotDimension),
Double(width * height) <= ProtocolBounds.screenshotPixels,
let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: width,
pixelsHigh: height,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
),
let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
return nil
}
bitmap.size = NSSize(width: width, height: height)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
image.draw(
in: NSRect(x: 0, y: 0, width: width, height: height),
from: NSRect(origin: .zero, size: image.size),
operation: .copy,
fraction: 1
)
context.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
return bitmap.representation(using: type, properties: properties)
}
guard let representation = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: representation) else { return nil }
return bitmap.representation(using: type, properties: properties)
Expand All @@ -319,17 +405,19 @@ extension BrowserWindowController {
return data as Data
}

func agentScreenshotSeriesPlan(mode: String) throws -> JSONValue {
try callAgent(
func agentScreenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue {
var args: [String: Any] = ["mode": mode]
if let region { args["region"] = region }
return try callAgent(
"return globalThis.__headlessAgent.screenshotPlan(args);",
arguments: ["args": ["mode": mode]]
arguments: ["args": args]
)
}

func agentScrollToCapturePoint(y: Double) throws -> JSONValue {
func agentScrollToCapturePoint(y: Double, document: String) throws -> JSONValue {
try callAgent(
"return await globalThis.__headlessAgent.scrollToCapturePoint(args);",
arguments: ["args": ["y": y]]
arguments: ["args": ["y": y, "document": document]]
)
}

Expand Down Expand Up @@ -595,12 +683,25 @@ extension BrowserWindowController: BrowserEngineSession {
data: screenshot.data, clipboardCopied: screenshot.clipboardCopied
)
}
func hostScreenshotRegionSlice(
reference: String, document: String, geometry: ScreenshotRegionGeometry,
point: ScreenshotSeriesPoint, format: ScreenshotFormat
) throws -> BrowserScreenshot {
guard let sliceTop = point.sliceTop, let sliceHeight = point.sliceHeight else {
throw ScreenshotSeriesError.invalidPlan
}
return BrowserScreenshot(data: try agentScreenshotData(parameters: [
"_region": .string(reference), "_document": .string(document),
"_geometry": .object(geometry.parameters),
"_sliceTop": .number(sliceTop), "_sliceHeight": .number(sliceHeight),
], format: format, copyToClipboard: false).data)
}
func hostRecordingFrame() throws -> Data { try agentScreenshot(parameters: [:]) }
func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue {
try agentScreenshotSeriesPlan(mode: mode)
func hostScreenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue {
try agentScreenshotSeriesPlan(mode: mode, region: region)
}
func hostScrollToCapturePoint(y: Double) throws -> JSONValue {
try agentScrollToCapturePoint(y: y)
func hostScrollToCapturePoint(y: Double, document: String) throws -> JSONValue {
try agentScrollToCapturePoint(y: y, document: document)
}
func hostQAReport() throws -> JSONValue { agentQAReport() }
func hostQAClear() throws -> JSONValue { agentQAClear() }
Expand Down
Loading
Loading