From 9544fb353c6c730730e96bb03f60a459850232bb Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Tue, 22 Sep 2026 17:56:34 +0530 Subject: [PATCH] feat: add region screenshot series --- .agents/skills/headless-computer-use/SKILL.md | 1 + .../references/commands.md | 5 +- CHANGELOG.md | 4 + README.md | 12 + apps/headless-rs/src/protocol.rs | 20 +- apps/headless-rs/tests/protocol_tests.rs | 19 ++ apps/headless/Host/AgentBridge.swift | 137 +++++++-- apps/headless/LinuxHost/BrowserProcess.swift | 67 ++++- apps/headless/LinuxHost/main.swift | 21 +- .../Sources/HeadlessProtocol/CLI.swift | 17 +- .../HeadlessProtocol/Capabilities.swift | 2 +- .../Sources/HeadlessProtocol/HostCore.swift | 52 +++- .../Sources/HeadlessProtocol/Protocol.swift | 14 +- .../HeadlessProtocol/ProtocolSchema.swift | 11 +- .../Resources/AgentRuntime.js | 118 +++++++- .../HeadlessProtocol/ScreenshotSeries.swift | 188 +++++++++++- .../Tests/Fixtures/region-capture.html | 30 ++ .../HeadlessProtocolTests/ProtocolTests.swift | 273 +++++++++++++++++- apps/headless/Tests/agent-runtime.test.mjs | 151 ++++++++++ apps/headless/Tests/fixture-server.mjs | 1 + apps/headless/Tests/linux-e2e.sh | 61 +++- apps/headless/Tests/macos-e2e.sh | 57 ++++ apps/headless/docs/COMMANDS.md | 10 +- apps/headless/docs/P1.md | 8 + docs/roadmap/architecture-decisions.md | 45 +++ docs/roadmap/improvements-backlog.md | 8 +- packages/headless-npm/src/generated.ts | 52 +++- packages/headless-npm/src/protocol.ts | 42 +++ packages/headless-npm/test/protocol.test.mjs | 19 ++ .../src/headless_sdk/_protocol.py | 39 +++ .../src/headless_sdk/generated.py | 47 ++- .../headless-python/tests/test_protocol.py | 12 + sdk/protocol-fixtures.json | 30 ++ sdk/protocol-schema.json | 2 +- 34 files changed, 1483 insertions(+), 92 deletions(-) create mode 100644 apps/headless/Tests/Fixtures/region-capture.html diff --git a/.agents/skills/headless-computer-use/SKILL.md b/.agents/skills/headless-computer-use/SKILL.md index 5a877cf..f6935a7 100644 --- a/.agents/skills/headless-computer-use/SKILL.md +++ b/.agents/skills/headless-computer-use/SKILL.md @@ -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 diff --git a/.agents/skills/headless-computer-use/references/commands.md b/.agents/skills/headless-computer-use/references/commands.md index a0cca41..7bb75a8 100644 --- a/.agents/skills/headless-computer-use/references/commands.md +++ b/.agents/skills/headless-computer-use/references/commands.md @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bb710..eaf9922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e4e4bea..8b1eebb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/apps/headless-rs/src/protocol.rs b/apps/headless-rs/src/protocol.rs index 2c9cc7d..9f83d6a 100644 --- a/apps/headless-rs/src/protocol.rs +++ b/apps/headless-rs/src/protocol.rs @@ -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")?; @@ -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) = ®ion { + 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()), diff --git a/apps/headless-rs/tests/protocol_tests.rs b/apps/headless-rs/tests/protocol_tests.rs index 0dfbb1d..2b1e93c 100644 --- a/apps/headless-rs/tests/protocol_tests.rs +++ b/apps/headless-rs/tests/protocol_tests.rs @@ -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( diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index ed06f89..bbbde02 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -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 { @@ -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] @@ -279,15 +318,26 @@ 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) } @@ -295,9 +345,45 @@ extension BrowserWindowController { 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) @@ -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]] ) } @@ -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() } diff --git a/apps/headless/LinuxHost/BrowserProcess.swift b/apps/headless/LinuxHost/BrowserProcess.swift index ff915e3..8888489 100644 --- a/apps/headless/LinuxHost/BrowserProcess.swift +++ b/apps/headless/LinuxHost/BrowserProcess.swift @@ -914,8 +914,23 @@ final class LinuxBrowserSession: @unchecked Sendable { "format": format.browserScreenshotFormat, "fromSurface": true, "captureBeyondViewport": false, ] if format == .jpeg { capture["quality"] = 88 } + var regionSliceArguments = try screenshotRegionSliceArguments(parameters) let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil - if hasTarget { + if var arguments = regionSliceArguments { + capture["captureBeyondViewport"] = true + let rectangle = try evaluate( + "return globalThis.__headlessAgent.regionSlice(args);", + input: ["args": arguments] + ) + guard case .object(let outer) = rectangle, + case .object(let rect)? = outer["document"], + case .object(let viewport)? = outer["viewport"] else { + throw CDPError.invalidResponse("region slice rectangle") + } + capture["clip"] = try screenshotClip(rect) + arguments["viewport"] = try screenshotRectangleArguments(viewport) + regionSliceArguments = arguments + } else if hasTarget { capture["captureBeyondViewport"] = true let args = try browserTargetArguments(parameters) let rectangle = try evaluate( @@ -946,9 +961,39 @@ final class LinuxBrowserSession: @unchecked Sendable { guard let base64 = response["data"] as? String, let data = Data(base64Encoded: base64) else { throw CDPError.invalidResponse("Page.captureScreenshot data") } + if let regionSliceArguments { + _ = try evaluate( + "return globalThis.__headlessAgent.regionSlice(args);", + input: ["args": regionSliceArguments] + ) + } return data } + private func screenshotRegionSliceArguments( + _ parameters: [String: JSONValue] + ) throws -> [String: Any]? { + let hasRegionSlice = ["_region", "_document", "_geometry", "_sliceTop", "_sliceHeight"] + .contains { parameters[$0] != nil } + guard hasRegionSlice else { return nil } + 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 CDPError.invalidResponse("region slice parameters") + } + return [ + "region": reference, "document": document, + "geometry": ["x": x, "y": y, "width": width, "height": height], + "sliceTop": sliceTop, "sliceHeight": sliceHeight, + ] + } + private func printPDF( parameters: [String: JSONValue], timeoutMilliseconds: Int32 @@ -972,17 +1017,19 @@ final class LinuxBrowserSession: @unchecked Sendable { return data } - func screenshotSeriesPlan(mode: String) throws -> JSONValue { - try evaluate( + func screenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue { + var args: [String: Any] = ["mode": mode] + if let region { args["region"] = region } + return try evaluate( "return globalThis.__headlessAgent.screenshotPlan(args);", - input: ["args": ["mode": mode]] + input: ["args": args] ) } - func scrollToCapturePoint(y: Double) throws -> JSONValue { + func scrollToCapturePoint(y: Double, document: String) throws -> JSONValue { try evaluate( "return await globalThis.__headlessAgent.scrollToCapturePoint(args);", - input: ["args": ["y": y]] + input: ["args": ["y": y, "document": document]] ) } @@ -1279,10 +1326,16 @@ final class LinuxBrowserSession: @unchecked Sendable { } private func screenshotClip(_ rect: [String: JSONValue]) throws -> [String: Any] { + var result = try screenshotRectangleArguments(rect) + result["scale"] = 1 + return result + } + + private func screenshotRectangleArguments(_ rect: [String: JSONValue]) throws -> [String: Any] { let rectangle = try BoundedScreenshotRectangle(rect) return [ "x": rectangle.x, "y": rectangle.y, - "width": rectangle.width, "height": rectangle.height, "scale": 1, + "width": rectangle.width, "height": rectangle.height, ] } diff --git a/apps/headless/LinuxHost/main.swift b/apps/headless/LinuxHost/main.swift index f1d0fe3..3168c83 100644 --- a/apps/headless/LinuxHost/main.swift +++ b/apps/headless/LinuxHost/main.swift @@ -134,12 +134,25 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession { } return BrowserScreenshot(data: try browserSession.screenshot(parameters: parameters, format: format)) } + 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 browserSession.screenshot(parameters: [ + "_region": .string(reference), "_document": .string(document), + "_geometry": .object(geometry.parameters), + "_sliceTop": .number(sliceTop), "_sliceHeight": .number(sliceHeight), + ], format: format)) + } func hostRecordingFrame() throws -> Data { try browserSession.recordingFrame() } - func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue { - try browserSession.screenshotSeriesPlan(mode: mode) + func hostScreenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue { + try browserSession.screenshotSeriesPlan(mode: mode, region: region) } - func hostScrollToCapturePoint(y: Double) throws -> JSONValue { - try browserSession.scrollToCapturePoint(y: y) + func hostScrollToCapturePoint(y: Double, document: String) throws -> JSONValue { + try browserSession.scrollToCapturePoint(y: y, document: document) } func hostQAReport() throws -> JSONValue { try browserSession.qaReport() } func hostQAClear() throws -> JSONValue { browserSession.diagnostics.clear() } diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index c98ca76..9bd481d 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -506,23 +506,32 @@ public struct CLIParser { let fullPage = removeFlag("--full-page", from: &args) let everyViewport = removeFlag("--every-viewport", from: &args) let bySection = removeFlag("--by-section", from: &args) + let byRegion = try removeOption("--by-region", from: &args) let role = try removeOption("--role", from: &args) let name = try removeOption("--name", from: &args) let output = try removeOption("--output", from: &args) let formatText = try removeOption("--format", from: &args) let clipboard = removeFlag("--clipboard", from: &args) let format = try screenshotFormat(explicit: formatText, output: output) - if everyViewport && bySection { throw CLIParseError.conflictingTarget } - if everyViewport || bySection { + let seriesModes = (everyViewport ? 1 : 0) + (bySection ? 1 : 0) + (byRegion == nil ? 0 : 1) + if seriesModes > 1 { throw CLIParseError.conflictingTarget } + if seriesModes == 1 { try requireEmpty(args) guard !fullPage, role == nil, name == nil else { throw CLIParseError.conflictingTarget } guard format.isImage else { throw CLIParseError.invalidOption("--format pdf") } guard !clipboard else { throw CLIParseError.invalidOption("--clipboard") } + if let byRegion { + guard byRegion.hasPrefix("@r"), !byRegion.dropFirst(2).isEmpty, + byRegion.dropFirst(2).allSatisfy({ $0.isASCII && $0.isNumber }) else { + throw CLIParseError.invalidOption(byRegion) + } + } var parameters: [String: JSONValue] = [ "fullPage": .bool(false), - "series": .string(everyViewport ? "viewport" : "section"), + "series": .string(everyViewport ? "viewport" : (bySection ? "section" : "region")), "format": .string(format.rawValue), ] + if let byRegion { parameters["region"] = .string(byRegion) } if let output { let prefix = try screenshotSeriesPrefix(output) parameters["outputPrefix"] = .string(prefix) @@ -886,7 +895,7 @@ Commands: capture-info screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] [--output FILE] [--clipboard] screenshot --full-page --format pdf [--output FILE.pdf] - screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] + screenshot --every-viewport|--by-section|--by-region @rN [--format png|jpg|jpeg] [--output PREFIX] artifacts list [--limit N] [--cursor CURSOR] record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index 759880d..c1a8169 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -215,7 +215,7 @@ public let capabilitiesDocument: JSONValue = { "budgetRangeEstimatedTokens": .array([.number(256), .number(16_000)]), "maximumOutlineDepth": .number(8), ]), - "screenshotSeries": stringArray(["viewport", "section"]), + "screenshotSeries": stringArray(["viewport", "section", "region"]), "localCommands": stringArray([ "config.describe", "config.get", "config.list", "config.reset", "config.set", "credentials.add", "credentials.list", "credentials.remove", "credentials.rename", diff --git a/apps/headless/Sources/HeadlessProtocol/HostCore.swift b/apps/headless/Sources/HeadlessProtocol/HostCore.swift index c4dc887..cb45172 100644 --- a/apps/headless/Sources/HeadlessProtocol/HostCore.swift +++ b/apps/headless/Sources/HeadlessProtocol/HostCore.swift @@ -48,9 +48,13 @@ public protocol BrowserEngineSession: AnyObject { func hostScreenshot( parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool ) throws -> BrowserScreenshot + func hostScreenshotRegionSlice( + reference: String, document: String, geometry: ScreenshotRegionGeometry, + point: ScreenshotSeriesPoint, format: ScreenshotFormat + ) throws -> BrowserScreenshot func hostRecordingFrame() throws -> Data - func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue - func hostScrollToCapturePoint(y: Double) throws -> JSONValue + func hostScreenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue + func hostScrollToCapturePoint(y: Double, document: String) throws -> JSONValue func hostQAReport() throws -> JSONValue func hostQAClear() throws -> JSONValue func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue @@ -887,28 +891,50 @@ public final class HostCore: @unchecked Sendable { ) throws -> JSONValue { let mode = parameters["series"]?.stringValue ?? "viewport" let format = try screenshotFormat(explicit: parameters["format"]?.stringValue, output: nil) - let plan = try parseScreenshotSeriesPlan(try session.hostScreenshotSeriesPlan(mode: mode)) + let regionReference = parameters["region"]?.stringValue + let rawPlan = try session.hostScreenshotSeriesPlan( + mode: mode, region: regionReference + ) + let plan = try parseScreenshotSeriesPlan(rawPlan, regionReference: regionReference) let prefix = try screenshotSeriesPrefix(parameters: parameters, mode: mode) - defer { _ = try? session.hostScrollToCapturePoint(y: plan.initialY) } let reserved = try reserveScreenshotSeriesArtifacts( store: artifacts, points: plan.points, prefix: prefix, mode: mode, format: format ) + var metadata: [JSONValue] = [] + var captureError: Error? do { - let metadata = try plan.points.enumerated().map { index, point -> JSONValue in - _ = try session.hostScrollToCapturePoint(y: point.y) - let screenshot = try session.hostScreenshot( - parameters: [:], format: format, copyToClipboard: false - ) + metadata = try plan.points.enumerated().map { index, point -> JSONValue in + _ = try session.hostScrollToCapturePoint(y: point.y, document: plan.document) + let screenshot: BrowserScreenshot + if let regionReference, let geometry = plan.region { + screenshot = try session.hostScreenshotRegionSlice( + reference: regionReference, document: plan.document, + geometry: geometry, point: point, format: format + ) + } else { + screenshot = try session.hostScreenshot( + parameters: [:], format: format, copyToClipboard: false + ) + } return try artifacts.writeReserved(screenshot.data, to: reserved[index]) } - return screenshotSeriesSummary( - mode: mode, points: plan.points, artifacts: metadata, - truncated: plan.truncated, totalPoints: plan.totalPoints - ) + } catch { + captureError = error + } + do { + _ = try session.hostScrollToCapturePoint(y: plan.initialY, document: plan.document) } catch { artifacts.discardReserved(reserved) throw error } + if let captureError { + artifacts.discardReserved(reserved) + throw captureError + } + return screenshotSeriesSummary( + mode: mode, points: plan.points, artifacts: metadata, + truncated: plan.truncated, totalPoints: plan.totalPoints + ) } private func startRecording( diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index bd13d9d..ef08ce0 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -306,9 +306,21 @@ public struct CommandRequest: Codable, Equatable, Sendable { try target(allowValue: false, required: false) let hasTarget = parameters["target"] != nil || parameters["role"] != nil || parameters["name"] != nil let series = try string("series", maximumBytes: 32) - if let series, !["viewport", "section"].contains(series) { + if let series, !["viewport", "section", "region"].contains(series) { throw ProtocolValidationError.invalidParameter("Invalid screenshot series") } + let region = try string("region", maximumBytes: 16) + if let region { + guard region.hasPrefix("@r"), !region.dropFirst(2).isEmpty, + region.dropFirst(2).allSatisfy({ $0.isASCII && $0.isNumber }) else { + throw ProtocolValidationError.invalidParameter("Invalid screenshot region reference") + } + } + if (series == "region") != (region != nil) { + throw ProtocolValidationError.invalidParameter( + "Region screenshot series requires exactly one region reference" + ) + } let format = try screenshotFormat( explicit: string("format", maximumBytes: 16), output: parameters["output"]?.stringValue diff --git a/apps/headless/Sources/HeadlessProtocol/ProtocolSchema.swift b/apps/headless/Sources/HeadlessProtocol/ProtocolSchema.swift index 867826c..4901531 100644 --- a/apps/headless/Sources/HeadlessProtocol/ProtocolSchema.swift +++ b/apps/headless/Sources/HeadlessProtocol/ProtocolSchema.swift @@ -351,7 +351,14 @@ public func protocolResultDefinition(for command: CommandName) -> ProtocolResult resultField("bytes", .number, required: false), resultField("createdAt", .number, required: false), resultField("artifacts", .array, required: false), + resultField( + "series", .string, required: false, values: ["viewport", "section", "region"] + ), + resultField("count", .number, required: false), + resultField("positions", .array, required: false), resultField("truncated", .boolean, required: false), + resultField("totalPoints", .number, required: false), + resultField("untrustedContent", .boolean, required: false), ]) case .artifactList: return result("ArtifactList", [ @@ -747,7 +754,8 @@ public let protocolCommandDefinitions: [CommandName: ProtocolCommandDefinition] command(.captureInfo, untrusted: true), command(.screenshot, targetParameters + [ boolean("fullPage"), string("output", maximumBytes: 128), - string("series", maximumBytes: 32, values: ["viewport", "section"]), + string("series", maximumBytes: 32, values: ["viewport", "section", "region"]), + string("region", maximumBytes: 16), string("outputPrefix", maximumBytes: 80), string( "format", maximumBytes: 16, values: ["png", "jpg", "jpeg", "pdf"], @@ -756,6 +764,7 @@ public let protocolCommandDefinitions: [CommandName: ProtocolCommandDefinition] boolean("clipboard"), ], capabilityNegotiated: true, constraints: [ "target, full-page, and series modes are mutually constrained", + "region is required only for region series and must be an @rN reference", "PDF requires full-page mode and no clipboard", ]), command(.artifactList, [ diff --git a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js index f2ab9fc..1b33eb4 100644 --- a/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js +++ b/apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js @@ -13,6 +13,10 @@ if (!globalThis.__headlessAgent) { const issuedRefs = new Set(); const issuedRegionRefs = new Set(); const maximumTrackedRegions = 256; + const maximumScreenshotDimension = 16384; + const maximumScreenshotPixels = 64000000; + const maximumRegionCaptureWidth = 4096; + const maximumRegionExtent = 100000000; let lastMutation = performance.now(); new MutationObserver(() => { lastMutation = performance.now(); }) .observe(document, {subtree: true, childList: true, attributes: true, characterData: true}); @@ -94,6 +98,27 @@ if (!globalThis.__headlessAgent) { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; + const regionIsFullyRenderable = element => { + const target = element.getBoundingClientRect(); + for (let current = element.parentElement; current instanceof Element; + current = current.parentElement) { + const style = getComputedStyle(current); + const ancestor = current.getBoundingClientRect(); + const overflow = style.overflow || ''; + const overflowX = style.overflowX || overflow; + const overflowY = style.overflowY || overflow; + const clipsX = /^(auto|clip|hidden|scroll)$/.test(overflowX); + const clipsY = /^(auto|clip|hidden|scroll)$/.test(overflowY); + if ((clipsX && (target.left < ancestor.left - 0.5 || target.right > ancestor.right + 0.5)) || + (clipsY && (target.top < ancestor.top - 0.5 || target.bottom > ancestor.bottom + 0.5))) { + return false; + } + const clipPath = style.clipPath || style.getPropertyValue?.('clip-path') || 'none'; + const contain = style.contain || style.getPropertyValue?.('contain') || 'none'; + if (clipPath !== 'none' || /(^|\s)(content|paint|strict)(\s|$)/.test(contain)) return false; + } + return true; + }; const uploadVisible = element => { if (!visible(element)) return false; const viewportWidth = document.documentElement.clientWidth || globalThis.innerWidth || 0; @@ -1005,10 +1030,49 @@ if (!globalThis.__headlessAgent) { return {points: result, truncated, totalPoints: truncated ? points.length : result.length}; }; const screenshotPlan = args => { - const mode = args.mode === 'section' ? 'section' : 'viewport'; + const mode = ['section', 'region'].includes(args.mode) ? args.mode : 'viewport'; const root = document.documentElement; const maximum = Math.max(0, root.scrollHeight - innerHeight); const initialY = Math.round(scrollY); + if (mode === 'region') { + const element = resolveRegion(args.region); + const rect = element.getBoundingClientRect(); + const x = rect.left + scrollX; + const y = rect.top + scrollY; + const width = rect.width; + const height = rect.height; + if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || + width <= 0 || height <= 0 || width > maximumRegionCaptureWidth || + x > maximumRegionExtent || y > maximumRegionExtent || height > maximumRegionExtent || + rect.left < -0.5 || rect.right > innerWidth + 0.5 || + !regionIsFullyRenderable(element)) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (invalid capture geometry)`); + } + const sliceHeight = Math.min( + innerHeight, maximumScreenshotDimension, Math.floor(maximumScreenshotPixels / width) + ); + if (!Number.isFinite(sliceHeight) || sliceHeight < 1) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (invalid capture geometry)`); + } + const totalPoints = Math.max(1, Math.ceil(height / sliceHeight)); + const truncated = totalPoints > 80; + const points = []; + const leadingCount = truncated ? 79 : totalPoints; + for (let index = 0; index < leadingCount; index += 1) { + const sliceTop = y + index * sliceHeight; + points.push({...capturePoint(Math.min(maximum, sliceTop), `region ${index + 1}`, 'region'), + sliceTop, sliceHeight: Math.min(sliceHeight, y + height - sliceTop)}); + } + if (truncated) { + const finalHeight = Math.min(sliceHeight, height); + const sliceTop = y + height - finalHeight; + points.push({...capturePoint(Math.min(maximum, sliceTop), `region ${totalPoints}`, 'region'), + sliceTop, sliceHeight: finalHeight}); + } + return {mode, document: authenticationDocument, initialY, + viewportHeight: innerHeight, contentHeight: root.scrollHeight, + region: {x, y, width, height}, points, truncated, totalPoints}; + } if (mode === 'viewport') { const step = Math.max(1, innerHeight); const totalPoints = maximum === 0 ? 1 : Math.ceil(maximum / step) + 1; @@ -1021,7 +1085,8 @@ if (!globalThis.__headlessAgent) { if (points.length === 0 || points[points.length - 1].y !== maximum) { points.push(capturePoint(maximum, `viewport ${totalPoints}`, 'viewport')); } - return {mode, initialY, viewportHeight: innerHeight, contentHeight: root.scrollHeight, + return {mode, document: authenticationDocument, initialY, + viewportHeight: innerHeight, contentHeight: root.scrollHeight, points, truncated, totalPoints}; } const candidates = Array.from(document.querySelectorAll('main h1,main h2,main h3,h1,h2,h3,section,[role="region"]')); @@ -1032,10 +1097,55 @@ if (!globalThis.__headlessAgent) { return capturePoint(rect.top + scrollY - 16, label, kind); }).sort((left, right) => left.y - right.y); const deduped = dedupePoints(points); - return {mode, initialY, viewportHeight: innerHeight, contentHeight: root.scrollHeight, + return {mode, document: authenticationDocument, initialY, + viewportHeight: innerHeight, contentHeight: root.scrollHeight, points: deduped.points, truncated: deduped.truncated, totalPoints: deduped.totalPoints}; }; + const regionSlice = args => { + if (args.document !== authenticationDocument) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (document changed: inspect again)`); + } + const element = resolveRegion(args.region); + const rect = element.getBoundingClientRect(); + if (!regionIsFullyRenderable(element)) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (region is clipped by an ancestor)`); + } + const current = { + x: rect.left + scrollX, y: rect.top + scrollY, + width: rect.width, height: rect.height + }; + const expected = args.geometry || {}; + if (![current.x, current.y, current.width, current.height].every(Number.isFinite) || + ['x', 'y', 'width', 'height'].some(key => + !Number.isFinite(Number(expected[key])) || Math.abs(current[key] - Number(expected[key])) > 0.5)) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (geometry changed: inspect again)`); + } + const top = Number(args.sliceTop); + const height = Number(args.sliceHeight); + if (!Number.isFinite(top) || !Number.isFinite(height) || height <= 0 || + top < current.y - 0.5 || top + height > current.y + current.height + 0.5) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (invalid capture slice)`); + } + const viewport = {x: current.x - scrollX, y: top - scrollY, width: current.width, height}; + const expectedViewport = args.viewport; + if (expectedViewport && ['x', 'y', 'width', 'height'].some(key => + !Number.isFinite(Number(expectedViewport[key])) || + Math.abs(viewport[key] - Number(expectedViewport[key])) > 0.5)) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (viewport changed during capture)`); + } + if (viewport.x < -0.5 || viewport.x + viewport.width > innerWidth + 0.5 || + viewport.y < -0.5 || viewport.y + viewport.height > innerHeight + 0.5) { + fail('REGION_NOT_FOUND', `REGION_NOT_FOUND:${args.region} (slice is outside the viewport)`); + } + return { + viewport, + document: {x: current.x, y: top, width: current.width, height} + }; + }; const scrollToCapturePoint = async args => { + if (args.document !== authenticationDocument) { + fail('OPERATION_FAILED', 'SCREENSHOT_DOCUMENT_CHANGED'); + } const maximum = Math.max(0, document.documentElement.scrollHeight - innerHeight); const requestedY = Math.min(maximum, Math.max(0, Number(args.y || 0))); scrollTo({top: requestedY, behavior: 'instant'}); @@ -1090,7 +1200,7 @@ if (!globalThis.__headlessAgent) { }; return { snapshot, click, fill, select, credentialFill, finishCredentialFill, press, inputTarget, fileInput, fileInputMetadata, - authentication, scroll, state, tour, screenshotPlan, scrollToCapturePoint, rectangle, styles, storage, + authentication, scroll, state, tour, screenshotPlan, regionSlice, scrollToCapturePoint, rectangle, styles, storage, performance: performanceSummary, animations }; })(); diff --git a/apps/headless/Sources/HeadlessProtocol/ScreenshotSeries.swift b/apps/headless/Sources/HeadlessProtocol/ScreenshotSeries.swift index 0cd8c82..a75d2bc 100644 --- a/apps/headless/Sources/HeadlessProtocol/ScreenshotSeries.swift +++ b/apps/headless/Sources/HeadlessProtocol/ScreenshotSeries.swift @@ -16,61 +16,214 @@ public struct ScreenshotSeriesPoint: Equatable, Sendable { public let y: Double public let label: String public let kind: String + public let sliceTop: Double? + public let sliceHeight: Double? - public init(y: Double, label: String, kind: String) { + public init( + y: Double, label: String, kind: String, + sliceTop: Double? = nil, sliceHeight: Double? = nil + ) { self.y = y self.label = label self.kind = kind + self.sliceTop = sliceTop + self.sliceHeight = sliceHeight + } +} + +public struct ScreenshotRegionGeometry: Equatable, Sendable { + public static let maximumDocumentExtent = 100_000_000.0 + public static let maximumCaptureWidth = 4_096.0 + + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(_ object: [String: JSONValue]) throws { + guard let x = object["x"]?.numberValue, let y = object["y"]?.numberValue, + let width = object["width"]?.numberValue, + let height = object["height"]?.numberValue, + x.isFinite, y.isFinite, width.isFinite, height.isFinite, + x >= 0, y >= 0, width > 0, height > 0, + x <= Self.maximumDocumentExtent, y <= Self.maximumDocumentExtent, + width <= Self.maximumCaptureWidth, + height <= Self.maximumDocumentExtent else { + throw ScreenshotSeriesError.invalidPlan + } + self.x = x + self.y = y + self.width = width + self.height = height + } + + public var parameters: [String: JSONValue] { + [ + "x": .number(x), "y": .number(y), + "width": .number(width), "height": .number(height), + ] } } public struct ScreenshotSeriesPlan: Equatable, Sendable { + public let document: String public let initialY: Double public let points: [ScreenshotSeriesPoint] public let truncated: Bool public let totalPoints: Int + public let region: ScreenshotRegionGeometry? - public init(initialY: Double, points: [ScreenshotSeriesPoint], truncated: Bool, totalPoints: Int) { + public init( + document: String, initialY: Double, points: [ScreenshotSeriesPoint], truncated: Bool, + totalPoints: Int, region: ScreenshotRegionGeometry? = nil + ) { + self.document = document self.initialY = initialY self.points = points self.truncated = truncated self.totalPoints = totalPoints + self.region = region } } -public func parseScreenshotSeriesPlan(_ plan: JSONValue) throws -> ScreenshotSeriesPlan { +public func parseScreenshotSeriesPlan( + _ plan: JSONValue, regionReference: String? = nil +) throws -> ScreenshotSeriesPlan { guard case .object(let object) = plan, case .array(let values)? = object["points"] else { throw ScreenshotSeriesError.invalidPlan } + guard let document = object["document"]?.stringValue, + document.count == 32, + document.allSatisfy({ $0.isHexDigit }) else { + throw ScreenshotSeriesError.invalidPlan + } let initialY = object["initialY"]?.numberValue ?? 0 guard initialY.isFinite, initialY >= 0 else { throw ScreenshotSeriesError.invalidPlan } - let points = try values.prefix(80).map { value -> ScreenshotSeriesPoint in + let region: ScreenshotRegionGeometry? + if case .object(let rawRegion)? = object["region"] { + region = try ScreenshotRegionGeometry(rawRegion) + } else { + region = nil + } + guard (regionReference != nil) == (region != nil) else { + throw ScreenshotSeriesError.invalidPlan + } + guard values.count <= 80 else { throw ScreenshotSeriesError.invalidPlan } + let points = try values.map { value -> ScreenshotSeriesPoint in guard case .object(let item) = value, let y = item["y"]?.numberValue, y.isFinite else { throw ScreenshotSeriesError.invalidPlan } + let sliceTop = item["sliceTop"]?.numberValue + let sliceHeight = item["sliceHeight"]?.numberValue + guard (sliceTop == nil) == (sliceHeight == nil) else { + throw ScreenshotSeriesError.invalidPlan + } + if let region, let sliceTop, let sliceHeight { + guard sliceTop.isFinite, sliceHeight.isFinite, + sliceTop >= region.y, sliceHeight > 0, + sliceTop + sliceHeight <= region.y + region.height + 0.5 else { + throw ScreenshotSeriesError.invalidPlan + } + _ = try BoundedScreenshotRectangle([ + "x": .number(region.x), "y": .number(sliceTop), + "width": .number(region.width), "height": .number(sliceHeight), + ]) + } else if region != nil || sliceTop != nil { + throw ScreenshotSeriesError.invalidPlan + } return ScreenshotSeriesPoint( y: y, label: String((item["label"]?.stringValue ?? "viewport").prefix(80)), - kind: String((item["kind"]?.stringValue ?? "viewport").prefix(32)) + kind: String((item["kind"]?.stringValue ?? "viewport").prefix(32)), + sliceTop: sliceTop, sliceHeight: sliceHeight ) } guard !points.isEmpty else { throw ScreenshotSeriesError.emptyPlan } - let truncated = object["truncated"]?.boolValue ?? (values.count > points.count) - let totalPointsValue = object["totalPoints"]?.numberValue ?? Double(values.count) + guard let truncated = object["truncated"]?.boolValue, + let totalPointsValue = object["totalPoints"]?.numberValue else { + throw ScreenshotSeriesError.invalidPlan + } guard totalPointsValue.isFinite, totalPointsValue.rounded(.towardZero) == totalPointsValue, totalPointsValue >= Double(points.count), totalPointsValue <= 100_000_000 else { throw ScreenshotSeriesError.invalidPlan } + guard truncated == (totalPointsValue > Double(points.count)) else { + throw ScreenshotSeriesError.invalidPlan + } + if let region { + try validateRegionTiling( + points: points, region: region, + truncated: truncated, totalPoints: Int(totalPointsValue) + ) + } return ScreenshotSeriesPlan( - initialY: initialY, + document: document, initialY: initialY, points: points, - truncated: truncated || values.count > points.count, - totalPoints: Int(totalPointsValue) + truncated: truncated, + totalPoints: Int(totalPointsValue), + region: region ) } +private func validateRegionTiling( + points: [ScreenshotSeriesPoint], region: ScreenshotRegionGeometry, + truncated: Bool, totalPoints: Int +) throws { + let tolerance = 0.5 + guard let firstHeight = points.first?.sliceHeight, + let firstTop = points.first?.sliceTop, + abs(firstTop - region.y) <= tolerance, + firstHeight > 0 else { + throw ScreenshotSeriesError.invalidPlan + } + let leadingCount = max(0, points.count - 1) + guard !truncated || (points.count == 80 && totalPoints > points.count) else { + throw ScreenshotSeriesError.invalidPlan + } + for index in 0.. previousTop + previousHeight else { + throw ScreenshotSeriesError.invalidPlan + } + } else if points.count > 1, + let previousTop = points[points.count - 2].sliceTop, + let previousHeight = points[points.count - 2].sliceHeight { + guard abs(lastTop - (previousTop + previousHeight)) <= tolerance else { + throw ScreenshotSeriesError.invalidPlan + } + } +} + public func screenshotSeriesPrefix(parameters: [String: JSONValue], mode: String) throws -> String { if let requested = parameters["outputPrefix"]?.stringValue { try validateArtifactPrefix(requested) @@ -110,12 +263,14 @@ public func screenshotSeriesSummary( totalPoints: Int? = nil ) -> JSONValue { let positions = points.enumerated().map { index, point in - JSONValue.object([ - "index": .number(Double(index + 1)), - "y": .number(point.y), - "label": .string(point.label), - "kind": .string(point.kind), - ]) + var position: [String: JSONValue] = [ + "index": .number(Double(index + 1)), "kind": .string(point.kind), + ] + if mode != "region" { + position["y"] = .number(point.y) + position["label"] = .string(point.label) + } + return JSONValue.object(position) } return .object([ "series": .string(mode), @@ -124,6 +279,7 @@ public func screenshotSeriesSummary( "positions": .array(positions), "truncated": .bool(truncated), "totalPoints": .number(Double(totalPoints ?? points.count)), + "untrustedContent": .bool(mode == "section" || mode == "region"), ]) } diff --git a/apps/headless/Tests/Fixtures/region-capture.html b/apps/headless/Tests/Fixtures/region-capture.html new file mode 100644 index 0000000..6b2022e --- /dev/null +++ b/apps/headless/Tests/Fixtures/region-capture.html @@ -0,0 +1,30 @@ + + + + + + Region capture fixture + + + +
+
+

Capture target

+

The red page background must not appear in region artifacts.

+
+
+ + diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 84f67b6..a529837 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -318,6 +318,9 @@ private final class TestBrowserSession: BrowserEngineSession { var saveAlias: CredentialAlias? private(set) var credentialPromptCount = 0 private(set) var savePromptCount = 0 + private(set) var screenshotScrollCount = 0 + var screenshotScrollFailureCall: Int? + var screenshotRegionFailure = false init(isolated: Bool = false) { hostIsolated = isolated @@ -361,14 +364,40 @@ private final class TestBrowserSession: BrowserEngineSession { func hostScreenshot( parameters: [String: JSONValue], format: ScreenshotFormat, copyToClipboard: Bool ) throws -> BrowserScreenshot { BrowserScreenshot(data: Data("image".utf8)) } + func hostScreenshotRegionSlice( + reference: String, document: String, geometry: ScreenshotRegionGeometry, + point: ScreenshotSeriesPoint, format: ScreenshotFormat + ) throws -> BrowserScreenshot { + if screenshotRegionFailure { + throw HostError(code: .operationFailed, message: "region capture failed") + } + return BrowserScreenshot(data: Data("region".utf8)) + } func hostRecordingFrame() throws -> Data { Data("frame".utf8) } - func hostScreenshotSeriesPlan(mode: String) throws -> JSONValue { - .object([ + func hostScreenshotSeriesPlan(mode: String, region: String?) throws -> JSONValue { + var plan: [String: JSONValue] = [ + "document": .string(String(repeating: "a", count: 32)), "initialY": .number(0), "totalPoints": .number(1), "truncated": .bool(false), "points": .array([.object(["y": .number(0), "label": .string("viewport")])]), - ]) + ] + if mode == "region", region != nil { + plan["region"] = .object([ + "x": .number(0), "y": .number(0), "width": .number(100), "height": .number(100), + ]) + plan["points"] = .array([.object([ + "y": .number(0), "label": .string("region 1"), "kind": .string("region"), + "sliceTop": .number(0), "sliceHeight": .number(100), + ])]) + } + return .object(plan) + } + func hostScrollToCapturePoint(y: Double, document: String) throws -> JSONValue { + screenshotScrollCount += 1 + if screenshotScrollFailureCall == screenshotScrollCount { + throw HostError(code: .operationFailed, message: "scroll restoration failed") + } + return .object(["y": .number(y)]) } - func hostScrollToCapturePoint(y: Double) throws -> JSONValue { .object(["y": .number(y)]) } func hostQAReport() throws -> JSONValue { .object(["issues": .array([])]) } func hostQAClear() throws -> JSONValue { .object(["cleared": .bool(true)]) } func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue { @@ -1009,6 +1038,37 @@ struct ProtocolTests { id: "valid-screenshot-series", command: .screenshot, parameters: ["series": .string("viewport"), "outputPrefix": .string("dashboard-scroll")] ).validate() + try CommandRequest( + id: "valid-region-screenshot-series", command: .screenshot, + parameters: [ + "series": .string("region"), "region": .string("@r4"), + "outputPrefix": .string("checkout"), + ] + ).validate() + try expectThrows("region series should require a region reference") { + try CommandRequest( + id: "missing-region", command: .screenshot, + parameters: ["series": .string("region")] + ).validate() + } + try expectThrows("region references should require region series mode") { + try CommandRequest( + id: "unused-region", command: .screenshot, + parameters: ["series": .string("viewport"), "region": .string("@r4")] + ).validate() + } + try expectThrows("malformed screenshot region references should fail") { + try CommandRequest( + id: "bad-region", command: .screenshot, + parameters: ["series": .string("region"), "region": .string("@r../4")] + ).validate() + } + try expectThrows("screenshot region references should require ASCII digits") { + try CommandRequest( + id: "unicode-region", command: .screenshot, + parameters: ["series": .string("region"), "region": .string("@r٤")] + ).validate() + } try CommandRequest( id: "valid-jpeg-screenshot", command: .screenshot, parameters: ["format": .string("jpeg"), "output": .string("dashboard.jpeg")] @@ -1396,6 +1456,20 @@ struct ProtocolTests { ]) try expect(sectionSeries.request?.parameters["series"] == .string("section"), "section series should parse") try expect(sectionSeries.request?.parameters["outputPrefix"] == .string("dashboard-sections"), "series prefix should strip image extension") + let regionSeries = try CLIParser().parse([ + "screenshot", "--by-region", "@r4", "--output", "checkout", + ]) + try expect(regionSeries.request?.parameters["series"] == .string("region"), "region series should parse") + try expect(regionSeries.request?.parameters["region"] == .string("@r4"), "region reference should parse") + try expectThrows("region screenshot must reject malformed references") { + _ = try CLIParser().parse(["screenshot", "--by-region", "main"]) + } + try expectThrows("region screenshot must reject Unicode digits") { + _ = try CLIParser().parse(["screenshot", "--by-region", "@r٤"]) + } + try expectThrows("region screenshot must reject another series mode") { + _ = try CLIParser().parse(["screenshot", "--by-region", "@r4", "--by-section"]) + } try expectThrows("series screenshot must reject element targets") { _ = try CLIParser().parse(["screenshot", "--every-viewport", "@e1"]) } @@ -3385,6 +3459,7 @@ struct ProtocolTests { static func screenshotSeriesHelpers() throws { let rawPlan = JSONValue.object([ + "document": .string(String(repeating: "a", count: 32)), "initialY": .number(240), "truncated": .bool(true), "totalPoints": .number(100), @@ -3399,10 +3474,136 @@ struct ProtocolTests { try expect(plan.truncated && plan.totalPoints == 100, "series plans should report truncation") try expectThrows("invalid initial scroll positions should fail") { _ = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "a", count: 32)), "initialY": .number(-1), "points": .array([.object(["y": .number(0)])]), ])) } + let regionPlan = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "b", count: 32)), + "initialY": .number(20), "totalPoints": .number(2), + "truncated": .bool(false), + "region": .object([ + "x": .number(10), "y": .number(100), + "width": .number(500), "height": .number(900), + ]), + "points": .array([ + .object([ + "y": .number(100), "kind": .string("region"), + "sliceTop": .number(100), "sliceHeight": .number(600), + ]), + .object([ + "y": .number(700), "kind": .string("region"), + "sliceTop": .number(700), "sliceHeight": .number(300), + ]), + ]), + ]), regionReference: "@r4") + try expect(regionPlan.region?.width == 500, "region geometry should remain bounded") + let fractionalSliceHeight = 10.0 + let fractionalRegionHeight = fractionalSliceHeight * 80 + 0.25 + var fractionalPoints = (0..<79).map { index in + JSONValue.object([ + "y": .number(Double(index) * fractionalSliceHeight), + "sliceTop": .number(Double(index) * fractionalSliceHeight), + "sliceHeight": .number(fractionalSliceHeight), + ]) + } + fractionalPoints.append(.object([ + "y": .number(fractionalRegionHeight - fractionalSliceHeight), + "sliceTop": .number(fractionalRegionHeight - fractionalSliceHeight), + "sliceHeight": .number(fractionalSliceHeight), + ])) + let fractionalPlan = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "c", count: 32)), + "totalPoints": .number(81), "truncated": .bool(true), + "region": .object([ + "x": .number(0), "y": .number(0), + "width": .number(500), "height": .number(fractionalRegionHeight), + ]), + "points": .array(fractionalPoints), + ]), regionReference: "@r5") + try expect( + fractionalPlan.truncated && fractionalPlan.totalPoints == 81, + "fractional omitted spans should remain valid truncated plans" + ) + let regionSummary = screenshotSeriesSummary( + mode: "region", points: regionPlan.points, artifacts: [] + ) + guard case .object(let regionSummaryObject) = regionSummary, + case .array(let positions)? = regionSummaryObject["positions"], + case .object(let firstPosition)? = positions.first else { + throw TestFailure(description: "region summary should contain bounded positions") + } + try expect(firstPosition["y"] == nil, "region summaries must not expose page coordinates") + try expect(firstPosition["label"] == nil, "region summaries must not expose page labels") + try expectThrows("region plans with gaps should fail") { + _ = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "b", count: 32)), + "totalPoints": .number(2), "truncated": .bool(false), + "region": .object([ + "x": .number(10), "y": .number(100), + "width": .number(500), "height": .number(900), + ]), + "points": .array([ + .object([ + "y": .number(100), "sliceTop": .number(100), + "sliceHeight": .number(600), + ]), + .object([ + "y": .number(750), "sliceTop": .number(750), + "sliceHeight": .number(250), + ]), + ]), + ]), regionReference: "@r4") + } + try expectThrows("region plans with overlaps should fail") { + _ = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "b", count: 32)), + "totalPoints": .number(2), "truncated": .bool(false), + "region": .object([ + "x": .number(10), "y": .number(100), + "width": .number(500), "height": .number(900), + ]), + "points": .array([ + .object([ + "y": .number(100), "sliceTop": .number(100), + "sliceHeight": .number(600), + ]), + .object([ + "y": .number(650), "sliceTop": .number(650), + "sliceHeight": .number(350), + ]), + ]), + ]), regionReference: "@r4") + } + try expectThrows("region plans wider than the portable capture bound should fail") { + _ = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "b", count: 32)), + "totalPoints": .number(1), "truncated": .bool(false), + "region": .object([ + "x": .number(10), "y": .number(100), + "width": .number(4097), "height": .number(600), + ]), + "points": .array([.object([ + "y": .number(100), "sliceTop": .number(100), + "sliceHeight": .number(600), + ])]), + ]), regionReference: "@r4") + } + try expectThrows("region slices outside frozen geometry should fail") { + _ = try parseScreenshotSeriesPlan(.object([ + "document": .string(String(repeating: "b", count: 32)), + "totalPoints": .number(1), "truncated": .bool(false), + "region": .object([ + "x": .number(10), "y": .number(100), + "width": .number(500), "height": .number(900), + ]), + "points": .array([.object([ + "y": .number(100), "sliceTop": .number(900), + "sliceHeight": .number(200), + ])]), + ]), regionReference: "@r4") + } let root = "/tmp/headless-series-test-\(UUID().uuidString)" defer { try? FileManager.default.removeItem(atPath: root) } @@ -3428,6 +3629,70 @@ struct ProtocolTests { !FileManager.default.fileExists(atPath: root + "/" + firstName), "partial series reservations should be removed after a later collision" ) + let atomicRoot = root + "/atomic" + let successSession = TestBrowserSession() + let successCore = HostCore( + engine: TestBrowserEngine(), + artifacts: try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": atomicRoot]), + defaultSession: successSession, + shutdownHandler: {} + ) + let successfulCapture = successCore.handle(CommandRequest( + command: .screenshot, + parameters: [ + "series": .string("region"), "region": .string("@r4"), + "outputPrefix": .string("capture-success"), + ] + )) + successCore.stop() + try expect(successfulCapture.ok, "valid region capture should succeed") + try expect(successSession.screenshotScrollCount == 2, "successful capture must restore scroll") + + let restorationSession = TestBrowserSession() + restorationSession.screenshotScrollFailureCall = 2 + let restorationCore = HostCore( + engine: TestBrowserEngine(), + artifacts: try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": atomicRoot]), + defaultSession: restorationSession, + shutdownHandler: {} + ) + let restorationFailure = restorationCore.handle(CommandRequest( + command: .screenshot, + parameters: [ + "series": .string("viewport"), "outputPrefix": .string("restore-failure"), + ] + )) + restorationCore.stop() + try expect(!restorationFailure.ok, "scroll restoration failure must fail the series") + let restorationArtifacts = try FileManager.default.contentsOfDirectory(atPath: atomicRoot) + try expect( + !restorationArtifacts.contains(where: { $0.hasPrefix("restore-failure") }), + "scroll restoration failure must remove completed artifacts" + ) + + let captureSession = TestBrowserSession() + captureSession.screenshotRegionFailure = true + let captureCore = HostCore( + engine: TestBrowserEngine(), + artifacts: try ArtifactStore(environment: ["HEADLESS_ARTIFACT_DIR": atomicRoot]), + defaultSession: captureSession, + shutdownHandler: {} + ) + let captureFailure = captureCore.handle(CommandRequest( + command: .screenshot, + parameters: [ + "series": .string("region"), "region": .string("@r4"), + "outputPrefix": .string("capture-failure"), + ] + )) + captureCore.stop() + try expect(!captureFailure.ok, "region capture failure must fail the series") + try expect(captureSession.screenshotScrollCount == 2, "failed capture must still restore scroll") + let captureArtifacts = try FileManager.default.contentsOfDirectory(atPath: atomicRoot) + try expect( + !captureArtifacts.contains(where: { $0.hasPrefix("capture-failure") }), + "region capture failure must remove every reservation" + ) try expect(RecordingFormat.webm.videoCodec == "vp9", "recording metadata should report the codec, not encoder") try expect( agentRuntimeJavaScript.contains("hints.push('select')"), diff --git a/apps/headless/Tests/agent-runtime.test.mjs b/apps/headless/Tests/agent-runtime.test.mjs index 8652d1d..04ecc6b 100644 --- a/apps/headless/Tests/agent-runtime.test.mjs +++ b/apps/headless/Tests/agent-runtime.test.mjs @@ -29,6 +29,11 @@ window.getComputedStyle = element => ({ display: element.style?.display || 'block', visibility: element.style?.visibility || 'visible', opacity: element.style?.opacity || '1', + overflow: element.style?.overflow || 'visible', + overflowX: element.style?.overflowX || element.style?.overflow || 'visible', + overflowY: element.style?.overflowY || element.style?.overflow || 'visible', + clipPath: element.style?.clipPath || 'none', + contain: element.style?.contain || 'none', getPropertyValue(property) { return element.style?.getPropertyValue(property) || ''; }, }); const positions = new WeakMap(); @@ -530,6 +535,152 @@ for (let index = 1; index < sectionPlan.points.length; index += 1) { ); } +// Region series bind an outline ref and one document to exact vertical slices. +const captureRegion = window.document.createElement('section'); +captureRegion.setAttribute('aria-label', 'Region capture fixture'); +captureRegion.textContent = 'Only these pixels belong in the region series.'; +const captureRegionContainer = window.document.createElement('div'); +window.document.body.append(captureRegionContainer); +captureRegionContainer.append(captureRegion); +let captureRegionTop = 400; +let captureRegionLeft = 40; +let captureRegionWidth = 700; +let captureRegionHeight = window.innerHeight * 2 + 100; +let captureRegionContainerHeight = captureRegionHeight + 100; +captureRegionContainer.getBoundingClientRect = () => ({ + x: captureRegionLeft, + y: captureRegionTop - window.scrollY, + top: captureRegionTop - window.scrollY, + left: captureRegionLeft, + right: captureRegionLeft + captureRegionWidth, + bottom: captureRegionTop - window.scrollY + captureRegionContainerHeight, + width: captureRegionWidth, + height: captureRegionContainerHeight, +}); +captureRegion.getBoundingClientRect = () => ({ + x: captureRegionLeft, + y: captureRegionTop - window.scrollY, + top: captureRegionTop - window.scrollY, + left: captureRegionLeft, + right: captureRegionLeft + captureRegionWidth, + bottom: captureRegionTop - window.scrollY + captureRegionHeight, + width: captureRegionWidth, + height: captureRegionHeight, +}); +const captureOutline = agent.snapshot(false, false, { + context: 'outline', task: 'Region capture fixture', limit: 20, budget: 1200, +}); +const captureRegionRef = captureOutline.regions.find( + region => region.name === 'Region capture fixture', +)?.ref; +assert.match(captureRegionRef ?? '', /^@r\d+$/); +window.scrollY = 0; +const regionPlan = agent.screenshotPlan({mode: 'region', region: captureRegionRef}); +assert.equal(regionPlan.points.length, 3); +assert.equal(regionPlan.truncated, false); +assert.equal(regionPlan.region.height, captureRegionHeight); +assert.equal(regionPlan.points.at(-1).sliceTop + regionPlan.points.at(-1).sliceHeight, + captureRegionTop + captureRegionHeight); +const firstRegionPoint = regionPlan.points[0]; +await agent.scrollToCapturePoint({y: firstRegionPoint.y, document: regionPlan.document}); +const firstRegionSlice = agent.regionSlice({ + region: captureRegionRef, + document: regionPlan.document, + geometry: regionPlan.region, + sliceTop: firstRegionPoint.sliceTop, + sliceHeight: firstRegionPoint.sliceHeight, +}); +assert.equal(firstRegionSlice.viewport.x, captureRegionLeft); +assert.equal(firstRegionSlice.viewport.y, 0); +assert.equal(firstRegionSlice.viewport.width, captureRegionWidth); +assert.equal(firstRegionSlice.viewport.height, window.innerHeight); +window.scrollY += 10; +assert.throws( + () => agent.regionSlice({ + region: captureRegionRef, + document: regionPlan.document, + geometry: regionPlan.region, + sliceTop: firstRegionPoint.sliceTop, + sliceHeight: firstRegionPoint.sliceHeight, + viewport: firstRegionSlice.viewport, + }), + error => error.headlessCode === 'REGION_NOT_FOUND' && /viewport changed/.test(error.message), +); +window.scrollY -= 10; +assert.throws( + () => agent.regionSlice({ + region: captureRegionRef, + document: '0'.repeat(32), + geometry: regionPlan.region, + sliceTop: firstRegionPoint.sliceTop, + sliceHeight: firstRegionPoint.sliceHeight, + }), + error => error.headlessCode === 'REGION_NOT_FOUND' && /document changed/.test(error.message), +); +captureRegionHeight += 10; +assert.throws( + () => agent.regionSlice({ + region: captureRegionRef, + document: regionPlan.document, + geometry: regionPlan.region, + sliceTop: firstRegionPoint.sliceTop, + sliceHeight: firstRegionPoint.sliceHeight, + }), + error => error.headlessCode === 'REGION_NOT_FOUND' && /geometry changed/.test(error.message), +); +captureRegionHeight = window.innerHeight * 100; +const truncatedRegionPlan = agent.screenshotPlan({mode: 'region', region: captureRegionRef}); +assert.equal(truncatedRegionPlan.points.length, 80); +assert.equal(truncatedRegionPlan.truncated, true); +assert(truncatedRegionPlan.totalPoints > truncatedRegionPlan.points.length); +assert.equal( + truncatedRegionPlan.points.at(-1).sliceTop + truncatedRegionPlan.points.at(-1).sliceHeight, + captureRegionTop + captureRegionHeight, +); +captureRegionHeight = window.innerHeight * 2 + 100; +captureRegionWidth = 4097; +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: captureRegionRef}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /invalid capture geometry/.test(error.message), +); +captureRegionWidth = 700; +captureRegionContainer.style.overflow = 'hidden'; +captureRegionContainerHeight = captureRegionHeight - 1; +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: captureRegionRef}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /invalid capture geometry/.test(error.message), +); +captureRegionContainer.style.overflow = 'visible'; +captureRegionContainerHeight = captureRegionHeight + 100; +captureRegionLeft = -1; +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: captureRegionRef}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /invalid capture geometry/.test(error.message), +); +captureRegionLeft = 40; +captureRegion.style.display = 'none'; +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: captureRegionRef}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /no longer visible/.test(error.message), +); +captureRegion.style.display = ''; +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: '@r999999'}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /unknown/.test(error.message), +); +const refreshedCaptureOutline = agent.snapshot(false, false, { + context: 'outline', task: 'Region capture fixture', limit: 20, budget: 1200, +}); +const refreshedCaptureRegionRef = refreshedCaptureOutline.regions.find( + region => region.name === 'Region capture fixture', +)?.ref; +assert.equal(refreshedCaptureRegionRef, captureRegionRef); +captureRegion.remove(); +assert.throws( + () => agent.screenshotPlan({mode: 'region', region: refreshedCaptureRegionRef}), + error => error.headlessCode === 'REGION_NOT_FOUND' && /no longer visible/.test(error.message), +); + // Once result arrays are exhausted, budget pruning must fall back to chopping // page text instead of returning an oversized response. const budgetedText = agent.snapshot(false, true, {context: 'full', limit: 1, budget: 256}); diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 4dbf90c..4f8fc1c 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -11,6 +11,7 @@ const routes = new Map([ ['/large-document', 'large-document.html'], ['/select', 'select.html'], ['/network-idle', 'network-idle.html'], + ['/region-capture', 'region-capture.html'], ['/auth-state', 'auth-state.html'], ['/auth-login', 'auth-login.html'], ['/file-upload', 'file-upload.html'], diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 949cde5..49fc31a 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -12,11 +12,12 @@ SUPERVISED_LAUNCHER_PID="" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" -mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/select" "$FIXTURE_ROOT/network-idle" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/auth-login" "$FIXTURE_ROOT/file-upload" "$FIXTURE_ROOT/api" "$FIXTURE_ROOT/allowlist-exits" "$FIXTURE_ROOT/allowlist-redirect" +mkdir -p "$FIXTURE_ROOT/designers/dashboard" "$FIXTURE_ROOT/next" "$FIXTURE_ROOT/hostile" "$FIXTURE_ROOT/large-document" "$FIXTURE_ROOT/region-capture" "$FIXTURE_ROOT/select" "$FIXTURE_ROOT/network-idle" "$FIXTURE_ROOT/trusted-input" "$FIXTURE_ROOT/auth-state" "$FIXTURE_ROOT/auth-login" "$FIXTURE_ROOT/file-upload" "$FIXTURE_ROOT/api" "$FIXTURE_ROOT/allowlist-exits" "$FIXTURE_ROOT/allowlist-redirect" cp /opt/headless/fixtures/dashboard.html "$FIXTURE_ROOT/designers/dashboard/index.html" cp /opt/headless/fixtures/next.html "$FIXTURE_ROOT/next/index.html" cp /opt/headless/fixtures/hostile.html "$FIXTURE_ROOT/hostile/index.html" cp /opt/headless/fixtures/large-document.html "$FIXTURE_ROOT/large-document/index.html" +cp /opt/headless/fixtures/region-capture.html "$FIXTURE_ROOT/region-capture/index.html" cp /opt/headless/fixtures/select.html "$FIXTURE_ROOT/select/index.html" cp /opt/headless/fixtures/network-idle.html "$FIXTURE_ROOT/network-idle/index.html" cp /opt/headless/fixtures/trusted-input.html "$FIXTURE_ROOT/trusted-input/index.html" @@ -509,6 +510,7 @@ if headless --session qa screenshot --format pdf --role button --name Continue - echo "element PDF capture was not rejected" >&2 exit 1 fi +STEP="screenshot-series" VIEWPORT_SERIES="$(headless --session qa screenshot --every-viewport --format jpg --output scroll-capture)" echo "$VIEWPORT_SERIES" | grep -q '"series":"viewport"' echo "$VIEWPORT_SERIES" | grep -q '"name":"scroll-capture-001.jpg"' @@ -519,6 +521,63 @@ SECTION_SERIES="$(headless --session qa screenshot --by-section --output section echo "$SECTION_SERIES" | grep -q '"series":"section"' SECTION_CAPTURE_COUNT="$(find "$HEADLESS_ARTIFACT_DIR" -maxdepth 1 -name 'section-capture-*.png' | wc -l | tr -d ' ')" test "$SECTION_CAPTURE_COUNT" -ge 3 +headless --session qa visit http://127.0.0.1:41739/region-capture | grep -q 'Region capture fixture' +REGION_OUTLINE="$(headless --session qa inspect --context outline --task 'Capture target' --limit 8 --budget 900)" +CAPTURE_REGION="$(printf %s "$REGION_OUTLINE" | python3 -c ' +import json, sys +regions = json.load(sys.stdin)["result"]["regions"] +print(next(region["ref"] for region in regions + if region.get("role") == "region" and region.get("name") == "Capture target")) +')" +test -n "$CAPTURE_REGION" +REGION_SERIES="$(headless --session qa screenshot --by-region "$CAPTURE_REGION" --output region-capture)" +echo "$REGION_SERIES" | grep -q '"series":"region"' +echo "$REGION_SERIES" | grep -q '"count":3' +echo "$REGION_SERIES" | grep -q '"totalPoints":3' +echo "$REGION_SERIES" | grep -q '"untrustedContent":true' +if echo "$REGION_SERIES" | grep -q '"label"\|"y"'; then + echo "region screenshot response exposed page labels or coordinates" >&2 + exit 1 +fi +REGION_CAPTURE_COUNT="$(find "$HEADLESS_ARTIFACT_DIR" -maxdepth 1 -name 'region-capture-*.png' | wc -l | tr -d ' ')" +test "$REGION_CAPTURE_COUNT" -eq 3 +python3 - "$HEADLESS_ARTIFACT_DIR" <<'PY' +import glob +import os +import struct +import sys + +paths = sorted(glob.glob(os.path.join(sys.argv[1], "region-capture-*.png"))) +dimensions = [] +for path in paths: + with open(path, "rb") as artifact: + header = artifact.read(24) + dimensions.append(struct.unpack(">II", header[16:24])) +assert len(dimensions) == 3, dimensions +assert all(width == 640 for width, _ in dimensions), dimensions +assert sum(height for _, height in dimensions) == 1600, dimensions +assert dimensions[0][1] == dimensions[1][1], dimensions +assert 0 < dimensions[-1][1] < dimensions[0][1], dimensions +PY +for artifact in "$HEADLESS_ARTIFACT_DIR"/region-capture-*.png; do + if ! ffmpeg -v error -i "$artifact" \ + -f rawvideo -pix_fmt rgb24 - 2>/dev/null | python3 -c ' +import sys +pixels = sys.stdin.buffer.read() +has_page_red = any(pixels[index] > 180 and pixels[index + 1] < 80 and pixels[index + 2] < 100 + for index in range(0, len(pixels) - 2, 3)) +sys.exit(0 if pixels and not has_page_red else 1) +'; then + echo "region screenshot included pixels outside the target: $artifact" >&2 + exit 1 + fi +done +if REGION_ERROR="$(headless --session qa screenshot --by-region @r999999 --output missing-region 2>&1)"; then + echo "unknown screenshot region unexpectedly succeeded" >&2 + exit 1 +fi +echo "$REGION_ERROR" | grep -q 'REGION_NOT_FOUND' +headless --session qa visit http://127.0.0.1:41739/designers/dashboard | grep -q 'Designers Dashboard' headless --session qa visual compare viewport.png viewport.png --output visual-diff.png | grep -q '"name":"visual-diff.png"' test -s "$HEADLESS_ARTIFACT_DIR/visual-diff.png" headless --session qa performance get | grep -q '"webVitals"' diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 6b02c37..3503f92 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -1445,6 +1445,63 @@ SECTION_SERIES="$("$CLI" --session qa screenshot --by-section --output section-c echo "$SECTION_SERIES" | grep -q '"series":"section"' SECTION_CAPTURE_COUNT="$(find "$HEADLESS_ARTIFACT_DIR" -maxdepth 1 -name 'section-capture-*.png' | wc -l | tr -d ' ')" test "$SECTION_CAPTURE_COUNT" -ge 3 +"$CLI" --session qa visit "http://127.0.0.1:$PORT/region-capture" | grep -q 'Region capture fixture' +REGION_OUTLINE="$("$CLI" --session qa inspect --context outline --task 'Capture target' --limit 8 --budget 900)" +CAPTURE_REGION="$(printf %s "$REGION_OUTLINE" | python3 -c ' +import json, sys +regions = json.load(sys.stdin)["result"]["regions"] +print(next(region["ref"] for region in regions + if region.get("role") == "region" and region.get("name") == "Capture target")) +')" +test -n "$CAPTURE_REGION" +REGION_SERIES="$("$CLI" --session qa screenshot --by-region "$CAPTURE_REGION" --output region-capture)" +echo "$REGION_SERIES" | grep -q '"series":"region"' +echo "$REGION_SERIES" | grep -q '"count":3' +echo "$REGION_SERIES" | grep -q '"totalPoints":3' +echo "$REGION_SERIES" | grep -q '"untrustedContent":true' +if echo "$REGION_SERIES" | grep -q '"label"\|"y"'; then + echo "region screenshot response exposed page labels or coordinates" >&2 + fail +fi +REGION_CAPTURE_COUNT="$(find "$HEADLESS_ARTIFACT_DIR" -maxdepth 1 -name 'region-capture-*.png' | wc -l | tr -d ' ')" +test "$REGION_CAPTURE_COUNT" -eq 3 +python3 - "$HEADLESS_ARTIFACT_DIR" <<'PY' +import glob +import os +import struct +import sys + +paths = sorted(glob.glob(os.path.join(sys.argv[1], "region-capture-*.png"))) +dimensions = [] +for path in paths: + with open(path, "rb") as artifact: + header = artifact.read(24) + dimensions.append(struct.unpack(">II", header[16:24])) +assert len(dimensions) == 3, dimensions +assert all(width == 640 for width, _ in dimensions), dimensions +assert sum(height for _, height in dimensions) == 1600, dimensions +assert dimensions[0][1] == dimensions[1][1], dimensions +assert 0 < dimensions[-1][1] < dimensions[0][1], dimensions +PY +for artifact in "$HEADLESS_ARTIFACT_DIR"/region-capture-*.png; do + if ! ffmpeg -v error -i "$artifact" \ + -f rawvideo -pix_fmt rgb24 - 2>/dev/null | python3 -c ' +import sys +pixels = sys.stdin.buffer.read() +has_page_red = any(pixels[index] > 180 and pixels[index + 1] < 80 and pixels[index + 2] < 100 + for index in range(0, len(pixels) - 2, 3)) +sys.exit(0 if pixels and not has_page_red else 1) +'; then + echo "region screenshot included pixels outside the target: $artifact" >&2 + fail + fi +done +if REGION_ERROR="$("$CLI" --session qa screenshot --by-region @r999999 --output missing-region 2>&1)"; then + echo "unknown screenshot region unexpectedly succeeded" >&2 + fail +fi +echo "$REGION_ERROR" | grep -q 'REGION_NOT_FOUND' +"$CLI" --session qa visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard' "$CLI" --session qa visual compare viewport.png viewport.png --output visual-diff.png | grep -q '"name":"visual-diff.png"' test -s "$HEADLESS_ARTIFACT_DIR/visual-diff.png" "$CLI" --session qa performance get | grep -q '"webVitals"' diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index b7159e8..b224808 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -274,7 +274,7 @@ back | reload capture-info screenshot [REF | --role ROLE --name NAME | --full-page] [--format png|jpg|jpeg] [--output FILE] [--clipboard] screenshot --full-page --format pdf [--output FILE.pdf] -screenshot --every-viewport|--by-section [--format png|jpg|jpeg] [--output PREFIX] +screenshot --every-viewport|--by-section|--by-region @rN [--format png|jpg|jpeg] [--output PREFIX] artifacts list [--limit N] [--cursor CURSOR] record start [--fps N] [--format mp4|mov|webm|gif] [--quality fast|balanced|high] [--output FILE] record status | record stop [--output FILE] @@ -290,6 +290,14 @@ report create [--output REPORT.json] file reader or a download manager. - `--clipboard` capture is macOS only. Linux rejects clipboard capture because VM clipboards are not a reliable boundary. +- `--by-region @rN` captures the referenced outline region's rendered border + box as vertical slices on both engines. The region must fit horizontally in + the viewport and cannot exceed 4096 CSS pixels in width; nested overflow is + not expanded. Region refs that are stale, + hidden, clipped, or change geometry during capture fail the whole series. +- Region series reserve every artifact before capture and remove them all on + capture, write, validation, or scroll-restoration failure. `truncated: true` + means the first 79 and final slices were returned with the middle omitted. - PDF screenshots and element-scoped capture follow the engine matrix reported by `capabilities`. - The recorder captures browser pixels through ffmpeg only: no OS chrome, no diff --git a/apps/headless/docs/P1.md b/apps/headless/docs/P1.md index 8691afb..d85d307 100644 --- a/apps/headless/docs/P1.md +++ b/apps/headless/docs/P1.md @@ -87,6 +87,7 @@ headless --session qa inspect --context full --text headless --session qa screenshot --full-page --output dashboard.png 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 record start --fps 10 headless --session qa record status headless --session qa record stop --output dashboard-flow.mp4 @@ -131,6 +132,13 @@ around visible headings, sections, and regions. Series output uses a prefix, not a single filename, restores the original scroll position, and still refuses paths or overwrites. Series screenshots support PNG and JPG/JPEG; PDF requires `--full-page` and cannot target an element. +`screenshot --by-region @rN --output PREFIX` instead captures the referenced +element's exact rendered border box as bounded vertical slices, with a portable +4096 CSS-pixel width ceiling. It rejects stale, hidden, horizontally clipped, +or changing regions and removes every +artifact if capture or scroll restoration fails. Nested overflow outside the +border box is not included. Truncated output contains the first 79 slices and +the final slice, with `totalPoints` reporting the full plan. ## Context pruning diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index e181d14..acd928f 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -1124,6 +1124,50 @@ for logging, even though callers still control their own CLI process arguments. --- +## 36. Region screenshots are bounded, exact-crop series + +**Decision:** `screenshot --by-region @rN` is a third screenshot-series mode +alongside viewport and section series. It accepts only an opaque region +reference issued by the current page runtime. It never accepts selectors, +element references, roles, or names. + +The isolated runtime freezes the region's document geometry and divides its +vertical extent into at most 80 bounded image slices. Each slice is no taller +than the viewport or the shared screenshot pixel limit permits, and the final +region extent is included when truncation is not required. Before each capture, +the runtime resolves the same region reference again, requires it to remain +connected and visible, verifies that its geometry has not changed, and returns +only the exact slice rectangle. Regions that are horizontally outside the +viewport, exceed the portable 4096 CSS-pixel width bound, mutate during +capture, or produce invalid geometry fail closed. + +A region reference means the referenced element's rendered border box, not the +broader logical scope used by `inspect --within`. Nested scroll-container +content outside that border box is not expanded. If more than 80 slices are +required, Headless captures the first 79 and final slice and reports both +`truncated: true` and the complete `totalPoints`; the missing middle is not +represented as complete coverage. + +HostCore reserves the complete artifact set before capture, discards every +reservation on failure, and requires restoration of the original scroll +position after both success and failure while the original document identity +remains active. WebKit captures the validated viewport rectangle; +Chromium captures the corresponding document rectangle through CDP. Both use +the same plan, artifact, truncation, and response contract. Region capture +does not add selectors, arbitrary JavaScript, page content, or coordinates to +logs or flows. Screenshot-series metadata is marked untrusted because section +labels originate in the page. + +**Status:** implemented for +[#211](https://github.com/LockInTime/headless/issues/211). + +**Consequences:** an agent can progress from outline inspection to visual +evidence for one semantic region without broad full-page capture. Very wide, +moving, sticky, or dynamically resizing regions require a fresh inspection or +a narrower region instead of yielding partial or misleading evidence. + +--- + ## Decision log | # | Decision | Status | Date | @@ -1156,5 +1200,6 @@ for logging, even though callers still control their own CLI process arguments. | 33 | Keep heavy agent benchmarks outside the product repository | Proposed | 2026-09-19 | | 34 | CDP-backed bounded network-idle wait | Implemented | 2026-09-20 | | 35 | Fixed native single-select operation | Implemented | 2026-09-20 | +| 36 | Bounded exact-crop region screenshot series | Implemented | 2026-09-22 | New decisions append here with the same format. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 8bf57ee..456342f 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -491,8 +491,12 @@ get an architecture-decision entry: **Done:** Chromium tracks bounded in-flight CDP requests and requires a 500 ms quiet window; WebKit declares and returns an explicit unsupported capability instead of approximating network state. -- **G6. Element screenshot on Linux `--by-region @rN`** — region-scoped series - capture, pairs naturally with the pruning ladder. +- **G6. Region screenshot series** ([#211](https://github.com/LockInTime/headless/issues/211)) — + ~~add bounded exact-crop `screenshot --by-region @rN` capture that pairs with + the pruning ladder and uses the portable series pipeline on both engines.~~ + **Done:** opaque current-document region refs produce an atomic, bounded + vertical series on both engines, with per-slice geometry revalidation, + enforced scroll restoration, explicit truncation, and untrusted metadata. - **G7. Trace export** — `capture-info` already returns an action trace; `report create` bundles it; a `--format junit`/markdown export would slot into CI comments. diff --git a/packages/headless-npm/src/generated.ts b/packages/headless-npm/src/generated.ts index 08b27a2..aa38c01 100644 --- a/packages/headless-npm/src/generated.ts +++ b/packages/headless-npm/src/generated.ts @@ -7,8 +7,8 @@ export type Untrusted = Readonly<{ readonly untrustedContent: true; readonly export const PROTOCOL_VERSION = "0.5" as const; export const PROTOCOL_SCHEMA_VERSION = 1 as const; export const MAXIMUM_MESSAGE_BYTES = 1048576 as const; -export const PROTOCOL_SCHEMA_SHA256 = "f82b2ea0d7e8b5d2db0a841c5d22976aec6f774303cd81791921977899679f1e" as const; -export const PROTOCOL_FIXTURES_SHA256 = "0b51ffaa2d3e3aaf0c32adcfeb02c180dcbe44face0d49e1c332b69f403ae062" as const; +export const PROTOCOL_SCHEMA_SHA256 = "db70dc1b07b034624e85ab8f07ac420d5af5b948edf999cd11e5445ed07d7a17" as const; +export const PROTOCOL_FIXTURES_SHA256 = "76bbee05cacd7ed16452176ad2973bf4604f4b864f8de1277664880c079a5435" as const; export const RESPONSE_ADDITIONAL_PROPERTIES = true as const; export const MAXIMUM_COMMAND_TIMEOUT_MS = 125000 as const; export const LOCAL_LIFECYCLE = { @@ -223,7 +223,8 @@ export interface ScreenshotParameters { readonly "name"?: string; readonly "fullPage"?: boolean; readonly "output"?: string; - readonly "series"?: "viewport" | "section"; + readonly "series"?: "viewport" | "section" | "region"; + readonly "region"?: string; readonly "outputPrefix"?: string; readonly "format"?: string; readonly "clipboard"?: boolean; @@ -479,7 +480,12 @@ export interface Screenshot { readonly "bytes"?: number; readonly "createdAt"?: number; readonly "artifacts"?: readonly JsonValue[]; + readonly "series"?: "viewport" | "section" | "region"; + readonly "count"?: number; + readonly "positions"?: readonly JsonValue[]; readonly "truncated"?: boolean; + readonly "totalPoints"?: number; + readonly "untrustedContent"?: boolean; readonly [key: string]: JsonValue; } @@ -1998,9 +2004,17 @@ export const COMMAND_METADATA = { "type": "string", "values": [ "viewport", - "section" + "section", + "region" ] }, + { + "maximumBytes": 16, + "name": "region", + "required": false, + "sensitive": false, + "type": "string" + }, { "maximumBytes": 80, "name": "outputPrefix", @@ -2064,10 +2078,40 @@ export const COMMAND_METADATA = { "required": false, "type": "array" }, + { + "name": "series", + "required": false, + "type": "string", + "values": [ + "viewport", + "section", + "region" + ] + }, + { + "name": "count", + "required": false, + "type": "number" + }, + { + "name": "positions", + "required": false, + "type": "array" + }, { "name": "truncated", "required": false, "type": "boolean" + }, + { + "name": "totalPoints", + "required": false, + "type": "number" + }, + { + "name": "untrustedContent", + "required": false, + "type": "boolean" } ], "name": "Screenshot", diff --git a/packages/headless-npm/src/protocol.ts b/packages/headless-npm/src/protocol.ts index 90e933f..845b1d1 100644 --- a/packages/headless-npm/src/protocol.ts +++ b/packages/headless-npm/src/protocol.ts @@ -153,6 +153,48 @@ export function validateParameters( validateParameter(command, definition, value); } } + if (command === "screenshot") { + const values = parameters as Readonly>; + const targetPresent = values.target !== undefined; + const semanticTargetPresent = values.role !== undefined || values.name !== undefined; + if (targetPresent && semanticTargetPresent) { + throw new ValidationError("screenshot element targets are mutually exclusive"); + } + if (targetPresent && !/^@e\d+$/.test(String(values.target))) { + throw new ValidationError("screenshot.target must be an element reference"); + } + if ((values.role !== undefined && values.role === "") + || (values.name !== undefined && values.name === "")) { + throw new ValidationError("screenshot semantic target values must not be empty"); + } + const hasTarget = targetPresent || semanticTargetPresent; + const series = typeof values.series === "string" ? values.series : undefined; + const regionPresent = values.region !== undefined; + if (regionPresent && !/^@r\d+$/.test(String(values.region))) { + throw new ValidationError("screenshot.region must be a region reference"); + } + if ((series === "region") !== regionPresent) { + throw new ValidationError("region screenshot series requires exactly one region reference"); + } + if (series !== undefined && (values.fullPage === true || hasTarget || values.output !== undefined)) { + throw new ValidationError("screenshot series conflicts with full-page, output, or element target"); + } + if (values.fullPage === true && hasTarget) { + throw new ValidationError("full-page and element screenshots are mutually exclusive"); + } + if (values.outputPrefix !== undefined && series === undefined) { + throw new ValidationError("screenshot.outputPrefix requires a screenshot series"); + } + const explicitFormat = typeof values.format === "string" ? values.format.toLowerCase() : ""; + const output = typeof values.output === "string" ? values.output.toLowerCase() : ""; + const pdf = explicitFormat === "pdf" || (!explicitFormat && output.endsWith(".pdf")); + if (series !== undefined && (pdf || values.clipboard === true)) { + throw new ValidationError("screenshot series does not support PDF or clipboard output"); + } + if (pdf && (values.fullPage !== true || hasTarget || values.clipboard === true)) { + throw new ValidationError("PDF screenshots require full-page mode without a target or clipboard"); + } + } if (command === "session.create") { validateSession(String(parameters.name)); } diff --git a/packages/headless-npm/test/protocol.test.mjs b/packages/headless-npm/test/protocol.test.mjs index a766349..01e565b 100644 --- a/packages/headless-npm/test/protocol.test.mjs +++ b/packages/headless-npm/test/protocol.test.mjs @@ -85,6 +85,25 @@ test("schema-driven validation mirrors portable Swift bounds", () => { () => validateParameters("auth.login", { challenge: "id", account: "work", password: "secret" }), /unknown parameter password/, ); + assert.doesNotThrow(() => validateParameters("screenshot", { + series: "region", region: "@r4", outputPrefix: "checkout", format: "png", + })); + assert.throws( + () => validateParameters("screenshot", { series: "region" }), + /requires exactly one region reference/, + ); + assert.throws( + () => validateParameters("screenshot", { series: "viewport", region: "@r4" }), + /requires exactly one region reference/, + ); + assert.throws( + () => validateParameters("screenshot", { series: "region", region: "@r4", clipboard: true }), + /does not support PDF or clipboard/, + ); + assert.throws( + () => validateParameters("screenshot", { fullPage: true, target: "@e1" }), + /mutually exclusive/, + ); }); test("generated scopes and timeout policies drive the SDK", () => { diff --git a/packages/headless-python/src/headless_sdk/_protocol.py b/packages/headless-python/src/headless_sdk/_protocol.py index cf0304a..0900b00 100644 --- a/packages/headless-python/src/headless_sdk/_protocol.py +++ b/packages/headless-python/src/headless_sdk/_protocol.py @@ -153,6 +153,45 @@ def validate_parameters(command: CommandName, parameters: Mapping[str, object]) raise ValidationError(f"{command} requires {name}") else: _validate_parameter(command, definition, parameters[name]) + if command == "screenshot": + target_present = "target" in parameters + semantic_target_present = "role" in parameters or "name" in parameters + if target_present and semantic_target_present: + raise ValidationError("screenshot element targets are mutually exclusive") + if target_present and re.fullmatch(r"@e\d+", cast(str, parameters["target"])) is None: + raise ValidationError("screenshot.target must be an element reference") + if parameters.get("role") == "" or parameters.get("name") == "": + raise ValidationError("screenshot semantic target values must not be empty") + has_target = target_present or semantic_target_present + series = parameters.get("series") + region_present = "region" in parameters + if region_present and re.fullmatch(r"@r\d+", cast(str, parameters["region"])) is None: + raise ValidationError("screenshot.region must be a region reference") + if (series == "region") != region_present: + raise ValidationError("region screenshot series requires exactly one region reference") + if series is not None and ( + parameters.get("fullPage") is True or has_target or "output" in parameters + ): + raise ValidationError( + "screenshot series conflicts with full-page, output, or element target" + ) + if parameters.get("fullPage") is True and has_target: + raise ValidationError("full-page and element screenshots are mutually exclusive") + if "outputPrefix" in parameters and series is None: + raise ValidationError("screenshot.outputPrefix requires a screenshot series") + explicit_format = cast(str, parameters.get("format", "")).lower() + output = cast(str, parameters.get("output", "")).lower() + pdf = explicit_format == "pdf" or (not explicit_format and output.endswith(".pdf")) + if series is not None and (pdf or parameters.get("clipboard") is True): + raise ValidationError("screenshot series does not support PDF or clipboard output") + if pdf and ( + parameters.get("fullPage") is not True + or has_target + or parameters.get("clipboard") is True + ): + raise ValidationError( + "PDF screenshots require full-page mode without a target or clipboard" + ) if command == "session.create": validate_session(cast(str, parameters["name"])) diff --git a/packages/headless-python/src/headless_sdk/generated.py b/packages/headless-python/src/headless_sdk/generated.py index 8c59797..d455b4f 100644 --- a/packages/headless-python/src/headless_sdk/generated.py +++ b/packages/headless-python/src/headless_sdk/generated.py @@ -13,8 +13,8 @@ PROTOCOL_SCHEMA_VERSION = 1 MAXIMUM_MESSAGE_BYTES = 1048576 MAXIMUM_COMMAND_TIMEOUT_SECONDS = 125.0 -PROTOCOL_SCHEMA_SHA256 = 'f82b2ea0d7e8b5d2db0a841c5d22976aec6f774303cd81791921977899679f1e' -PROTOCOL_FIXTURES_SHA256 = '0b51ffaa2d3e3aaf0c32adcfeb02c180dcbe44face0d49e1c332b69f403ae062' +PROTOCOL_SCHEMA_SHA256 = 'db70dc1b07b034624e85ab8f07ac420d5af5b948edf999cd11e5445ed07d7a17' +PROTOCOL_FIXTURES_SHA256 = '76bbee05cacd7ed16452176ad2973bf4604f4b864f8de1277664880c079a5435' RESPONSE_ADDITIONAL_PROPERTIES = True COMMAND_ERROR_CODES = ('ARTIFACT_ERROR', 'AUTH_ACCOUNT_NOT_FOUND', 'AUTH_CHALLENGE_CONSUMED', 'AUTH_CHALLENGE_EXPIRED', 'AUTH_CHALLENGE_NOT_FOUND', 'AUTH_FORM_CHANGED', 'AUTH_ORIGIN_CHANGED', 'AUTH_REQUIRED', 'CREDENTIAL_ALIAS_EXISTS', 'ELEMENT_NOT_FOUND', 'FLOW_FAILED', 'HOST_STOPPING', 'HOST_UNAVAILABLE', 'INTERNAL_ERROR', 'INVALID_CAPTURE_FORMAT', 'INVALID_COMMAND', 'INVALID_FLOW', 'INVALID_INPUT', 'INVALID_REQUEST', 'INVALID_SESSION', 'MISSING_PARAMETER', 'OPERATION_FAILED', 'PAGINATION_CURSOR_EXPIRED', 'PAGINATION_CURSOR_INVALID', 'PAGINATION_CURSOR_SCOPE_MISMATCH', 'PAGINATION_CURSOR_STALE', 'PEER_DENIED', 'RECORDER_UNAVAILABLE', 'RECORDING_ACTIVE', 'RECORDING_FAILED', 'RECORDING_NOT_ACTIVE', 'REGION_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'SENSITIVE_DIAGNOSTICS_DISABLED', 'SESSION_EXISTS', 'SESSION_NOT_FOUND', 'TIMEOUT', 'UNSAFE_NAVIGATION', 'UNSAFE_RESOURCE_TYPE', 'UNSUPPORTED_CAPABILITY', 'USER_PRESENCE_DENIED', 'USER_PRESENCE_UNAVAILABLE', 'VAULT_LOCKED', 'VAULT_OPERATION_FAILED', 'VAULT_RESPONSE_INVALID', 'VAULT_UNAVAILABLE') CommandErrorCode = Literal['ARTIFACT_ERROR', 'AUTH_ACCOUNT_NOT_FOUND', 'AUTH_CHALLENGE_CONSUMED', 'AUTH_CHALLENGE_EXPIRED', 'AUTH_CHALLENGE_NOT_FOUND', 'AUTH_FORM_CHANGED', 'AUTH_ORIGIN_CHANGED', 'AUTH_REQUIRED', 'CREDENTIAL_ALIAS_EXISTS', 'ELEMENT_NOT_FOUND', 'FLOW_FAILED', 'HOST_STOPPING', 'HOST_UNAVAILABLE', 'INTERNAL_ERROR', 'INVALID_CAPTURE_FORMAT', 'INVALID_COMMAND', 'INVALID_FLOW', 'INVALID_INPUT', 'INVALID_REQUEST', 'INVALID_SESSION', 'MISSING_PARAMETER', 'OPERATION_FAILED', 'PAGINATION_CURSOR_EXPIRED', 'PAGINATION_CURSOR_INVALID', 'PAGINATION_CURSOR_SCOPE_MISMATCH', 'PAGINATION_CURSOR_STALE', 'PEER_DENIED', 'RECORDER_UNAVAILABLE', 'RECORDING_ACTIVE', 'RECORDING_FAILED', 'RECORDING_NOT_ACTIVE', 'REGION_NOT_FOUND', 'RESPONSE_TOO_LARGE', 'SENSITIVE_DIAGNOSTICS_DISABLED', 'SESSION_EXISTS', 'SESSION_NOT_FOUND', 'TIMEOUT', 'UNSAFE_NAVIGATION', 'UNSAFE_RESOURCE_TYPE', 'UNSUPPORTED_CAPABILITY', 'USER_PRESENCE_DENIED', 'USER_PRESENCE_UNAVAILABLE', 'VAULT_LOCKED', 'VAULT_OPERATION_FAILED', 'VAULT_RESPONSE_INVALID', 'VAULT_UNAVAILABLE'] @@ -113,7 +113,8 @@ class ScreenshotParameters(TypedDict): name: NotRequired[str] fullPage: NotRequired[bool] output: NotRequired[str] - series: NotRequired[Literal['viewport', 'section']] + series: NotRequired[Literal['viewport', 'section', 'region']] + region: NotRequired[str] outputPrefix: NotRequired[str] format: NotRequired[str] clipboard: NotRequired[bool] @@ -318,7 +319,12 @@ class Screenshot(TypedDict, total=False): bytes: NotRequired[int | float] createdAt: NotRequired[int | float] artifacts: NotRequired[list[JsonValue]] + series: NotRequired[Literal['viewport', 'section', 'region']] + count: NotRequired[int | float] + positions: NotRequired[list[JsonValue]] truncated: NotRequired[bool] + totalPoints: NotRequired[int | float] + untrustedContent: NotRequired[bool] class ArtifactList(TypedDict, total=False): directory: Required[str] @@ -1315,6 +1321,8 @@ class AuthenticationRequired(TypedDict, total=False): 'timeout': {'defaultMilliseconds': 15000, 'parameterPresentOverrides': {}}}, 'screenshot': {'capabilityNegotiated': True, 'constraints': ['target, full-page, and series modes are mutually constrained', + 'region is required only for region series and must be an @rN ' + 'reference', 'PDF requires full-page mode and no clipboard'], 'parameters': [{'maximumBytes': 16, 'name': 'target', @@ -1345,7 +1353,12 @@ class AuthenticationRequired(TypedDict, total=False): 'required': False, 'sensitive': False, 'type': 'string', - 'values': ['viewport', 'section']}, + 'values': ['viewport', 'section', 'region']}, + {'maximumBytes': 16, + 'name': 'region', + 'required': False, + 'sensitive': False, + 'type': 'string'}, {'maximumBytes': 80, 'name': 'outputPrefix', 'required': False, @@ -1382,7 +1395,23 @@ class AuthenticationRequired(TypedDict, total=False): {'name': 'artifacts', 'required': False, 'type': 'array'}, + {'name': 'series', + 'required': False, + 'type': 'string', + 'values': ['viewport', 'section', 'region']}, + {'name': 'count', + 'required': False, + 'type': 'number'}, + {'name': 'positions', + 'required': False, + 'type': 'array'}, {'name': 'truncated', + 'required': False, + 'type': 'boolean'}, + {'name': 'totalPoints', + 'required': False, + 'type': 'number'}, + {'name': 'untrustedContent', 'required': False, 'type': 'boolean'}], 'name': 'Screenshot', @@ -2208,7 +2237,8 @@ def screenshot( name: str | None = None, full_page: bool | None = None, output: str | None = None, - series: Literal['viewport', 'section'] | None = None, + series: Literal['viewport', 'section', 'region'] | None = None, + region: str | None = None, output_prefix: str | None = None, format: str | None = None, clipboard: bool | None = None, @@ -2229,6 +2259,8 @@ def screenshot( parameters['output'] = cast(JsonValue, output) if series is not None: parameters['series'] = cast(JsonValue, series) + if region is not None: + parameters['region'] = cast(JsonValue, region) if output_prefix is not None: parameters['outputPrefix'] = cast(JsonValue, output_prefix) if format is not None: @@ -2881,7 +2913,8 @@ async def screenshot( name: str | None = None, full_page: bool | None = None, output: str | None = None, - series: Literal['viewport', 'section'] | None = None, + series: Literal['viewport', 'section', 'region'] | None = None, + region: str | None = None, output_prefix: str | None = None, format: str | None = None, clipboard: bool | None = None, @@ -2902,6 +2935,8 @@ async def screenshot( parameters['output'] = cast(JsonValue, output) if series is not None: parameters['series'] = cast(JsonValue, series) + if region is not None: + parameters['region'] = cast(JsonValue, region) if output_prefix is not None: parameters['outputPrefix'] = cast(JsonValue, output_prefix) if format is not None: diff --git a/packages/headless-python/tests/test_protocol.py b/packages/headless-python/tests/test_protocol.py index 7fbbbd0..7614396 100644 --- a/packages/headless-python/tests/test_protocol.py +++ b/packages/headless-python/tests/test_protocol.py @@ -95,6 +95,18 @@ def test_schema_driven_validation_matches_swift_bounds() -> None: "auth.login", {"challenge": "id", "account": "work", "password": "secret"}, ) + create_request( + "screenshot", + {"series": "region", "region": "@r4", "outputPrefix": "checkout", "format": "png"}, + ) + with pytest.raises(ValidationError, match="requires exactly one region reference"): + create_request("screenshot", {"series": "region"}) + with pytest.raises(ValidationError, match="requires exactly one region reference"): + create_request("screenshot", {"series": "viewport", "region": "@r4"}) + with pytest.raises(ValidationError, match="does not support PDF or clipboard"): + create_request("screenshot", {"series": "region", "region": "@r4", "clipboard": True}) + with pytest.raises(ValidationError, match="mutually exclusive"): + create_request("screenshot", {"fullPage": True, "target": "@e1"}) with pytest.raises(ValidationError, match="finite number"): create_request("wait", {"timeoutMs": 10**1000}) with pytest.raises(ValidationError, match="request id is invalid"): diff --git a/sdk/protocol-fixtures.json b/sdk/protocol-fixtures.json index 86d4ce7..2769b74 100644 --- a/sdk/protocol-fixtures.json +++ b/sdk/protocol-fixtures.json @@ -76,6 +76,36 @@ "originalActionReplayed": false } } + }, + { + "name": "region screenshot series", + "argv": ["screenshot", "--by-region", "@r4", "--output", "checkout"], + "request": { + "id": "fixture-region-screenshot", + "version": "0.5", + "command": "screenshot", + "parameters": { + "fullPage": false, + "series": "region", + "region": "@r4", + "format": "png", + "outputPrefix": "checkout" + } + }, + "response": { + "id": "fixture-region-screenshot", + "version": "0.5", + "ok": true, + "result": { + "series": "region", + "count": 1, + "artifacts": [], + "positions": [{"index": 1, "kind": "region"}], + "truncated": false, + "totalPoints": 1, + "untrustedContent": true + } + } } ], "directRequests": [ diff --git a/sdk/protocol-schema.json b/sdk/protocol-schema.json index 65fa928..0ad10b6 100644 --- a/sdk/protocol-schema.json +++ b/sdk/protocol-schema.json @@ -1 +1 @@ -{"cancellation":{"afterSend":"close the client connection; command outcome is unknown until state is inspected","beforeSend":"request is not written","ownedHost":"terminate, await, and reap only a host process launched by that SDK instance","sharedHost":"never stop the host implicitly"},"commands":[{"capabilityNegotiated":false,"constraints":[],"name":"ping","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"ready","required":true,"type":"boolean"},{"name":"pid","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"},{"name":"platform","required":true,"type":"string"},{"name":"productVersion","required":true,"type":"string"},{"name":"protocolVersion","required":true,"type":"string"},{"name":"capabilities","required":true,"type":"object"},{"name":"recordingAvailable","required":true,"type":"boolean"},{"name":"artifactDirectory","required":true,"type":"string"},{"name":"navigationAllowlist","required":true,"type":"array"}],"name":"HostStatus","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"shutdown","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"stopping","required":true,"type":"boolean"}],"name":"Shutdown","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"profile.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"boolean"},{"name":"session","required":true,"type":"string"}],"name":"ProfileClear","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.create","parameters":[{"maximumBytes":64,"name":"name","required":true,"sensitive":false,"type":"string"},{"name":"isolated","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"session","required":true,"type":"string"},{"name":"isolated","required":true,"type":"boolean"}],"name":"SessionCreate","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.list","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"items":{"type":"string"},"name":"sessions","required":true,"type":"array"},{"items":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"isolated","required":true,"type":"boolean"},{"name":"ageMs","required":true,"type":"number"},{"name":"status","required":true,"type":"string","values":["available","navigating","unavailable"]},{"name":"url","required":true,"type":"string-or-null"},{"name":"title","required":true,"type":"string-or-null"},{"name":"urlTruncated","required":true,"type":"boolean"},{"name":"titleTruncated","required":true,"type":"boolean"},{"name":"untrustedContent","required":true,"type":"boolean"}],"name":"SessionDetail","type":"object"},"name":"details","required":true,"type":"array"}],"name":"SessionList","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.close","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"closed","required":true,"type":"string"}],"name":"SessionClose","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"visit","parameters":[{"maximumBytes":8192,"name":"url","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"inspect","parameters":[{"name":"interactive","required":false,"sensitive":false,"type":"boolean"},{"name":"text","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":16,"name":"context","required":false,"sensitive":false,"type":"string","values":["summary","outline","text","actions","full"]},{"maximumBytes":512,"name":"task","required":false,"sensitive":false,"type":"string"},{"maximumBytes":16,"name":"within","required":false,"sensitive":false,"type":"string"},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximum":16000,"minimum":256,"name":"budget","required":false,"sensitive":false,"type":"integer"},{"maximum":8,"minimum":0,"name":"depth","required":false,"sensitive":false,"type":"integer"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"contextMode","required":true,"type":"string"},{"name":"viewport","required":true,"type":"object"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"elements","required":false,"type":"array"},{"name":"regions","required":false,"type":"array"},{"name":"snippets","required":false,"type":"array"},{"name":"text","required":false,"type":"string"}],"name":"Inspection","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"click","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"clicked","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"}],"name":"Click","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"fill","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":900000,"name":"value","required":true,"sensitive":true,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"filled","required":true,"type":"string"},{"name":"valueLength","required":true,"type":"number"}],"name":"Fill","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target","exactly one option label or value","native single-selection HTML select controls only"],"name":"select","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"label","required":false,"sensitive":true,"type":"string"},{"maximumBytes":1000,"name":"value","required":false,"sensitive":true,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"selected","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"optionIndex","required":true,"type":"number"}],"name":"Select","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["artifact must be an existing private-store basename","exactly one target reference or semantic role/name target"],"name":"upload","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"artifact","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"uploaded","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"artifact","required":true,"type":"string"}],"name":"Upload","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"press","parameters":[{"maximumBytes":32,"name":"key","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"pressed","required":true,"type":"string"}],"name":"Press","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"scroll","parameters":[{"maximumBytes":8192,"name":"direction","required":false,"sensitive":false,"type":"string","values":["up","down","top","bottom"]},{"maximum":100000,"minimum":0.1,"name":"amount","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"direction","required":true,"type":"string"},{"name":"amount","required":true,"type":"number"}],"name":"Scroll","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"back","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"reload","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"wait","parameters":[{"name":"settled","required":false,"sensitive":false,"type":"boolean"},{"name":"networkIdle","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":8192,"name":"url","required":false,"sensitive":false,"type":"string"},{"maximumBytes":30000,"name":"text","required":false,"sensitive":false,"type":"string"},{"maximum":120000,"minimum":100,"name":"timeoutMs","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"maximumMilliseconds":125000,"minimumMilliseconds":10000,"parameterGraceMilliseconds":5000,"parameterName":"timeoutMs","parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"tour","parameters":[{"name":"fullPage","required":false,"sensitive":false,"type":"boolean"},{"maximum":5000,"minimum":100,"name":"pace","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"start","required":true,"type":"number"},{"name":"end","required":true,"type":"number"},{"name":"durationMs","required":true,"type":"number"}],"name":"Tour","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":125000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"capture.info","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"engine","required":true,"type":"string"},{"name":"page","required":true,"type":"object"},{"name":"trace","required":true,"type":"array"},{"name":"recording","required":true,"type":"object"}],"name":"CaptureInfo","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["target, full-page, and series modes are mutually constrained","PDF requires full-page mode and no clipboard"],"name":"screenshot","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"name":"fullPage","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"},{"maximumBytes":32,"name":"series","required":false,"sensitive":false,"type":"string","values":["viewport","section"]},{"maximumBytes":80,"name":"outputPrefix","required":false,"sensitive":false,"type":"string"},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"format","required":false,"sensitive":false,"type":"string","values":["png","jpg","jpeg","pdf"]},{"name":"clipboard","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":false,"type":"string"},{"name":"path","required":false,"type":"string"},{"name":"kind","required":false,"type":"string"},{"name":"bytes","required":false,"type":"number"},{"name":"createdAt","required":false,"type":"number"},{"name":"artifacts","required":false,"type":"array"},{"name":"truncated","required":false,"type":"boolean"}],"name":"Screenshot","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":30000,"parameterPresentOverrides":{"series":125000}}},{"capabilityNegotiated":false,"constraints":[],"name":"artifact.list","parameters":[{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"directory","required":true,"type":"string"},{"name":"artifacts","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"total","required":true,"type":"number"},{"name":"omitted","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"ArtifactList","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.start","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"},{"maximum":30,"minimum":1,"name":"fps","required":false,"sensitive":false,"type":"number"},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"format","required":false,"sensitive":false,"type":"string","values":["mp4","mov","webm","gif"]},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"quality","required":false,"sensitive":false,"type":"string","values":["fast","balanced","high"]}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.status","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.stop","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":30000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"qa.report","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"summary","required":true,"type":"object"},{"name":"issues","required":true,"type":"array"},{"name":"events","required":true,"type":"array"},{"name":"omitted","required":true,"type":"object"},{"name":"truncated","required":true,"type":"boolean"}],"name":"QAReport","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"qa.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"number"}],"name":"QAClear","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"console.list","parameters":[{"maximumBytes":16,"name":"level","required":false,"sensitive":false,"type":"string","values":["all","log","info","debug","warn","error","assert"]},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"messages","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"ConsoleList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"network.list","parameters":[{"name":"failed","required":false,"sensitive":false,"type":"boolean"},{"maximum":599,"minimum":100,"name":"status","required":false,"sensitive":false,"type":"number"},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"requests","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"NetworkList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"network.get","parameters":[{"maximumBytes":128,"name":"requestId","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"found","required":true,"type":"boolean"},{"name":"requestId","required":false,"type":"string"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"request","required":false,"type":"object"}],"name":"NetworkDetail","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"styles.get","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"itemMaximumBytes":128,"maximumItems":64,"name":"properties","required":false,"sensitive":false,"type":"string-array"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"ref","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"box","required":true,"type":"object"},{"name":"styles","required":true,"type":"object"}],"name":"Styles","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["values require the sensitive diagnostics environment gate"],"name":"cookies.list","parameters":[{"name":"includeValues","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"cookies","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"}],"name":"CookieList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["values require the sensitive diagnostics environment gate"],"name":"storage.list","parameters":[{"maximumBytes":16,"name":"scope","required":false,"sensitive":false,"type":"string","values":["local","session","all"]},{"name":"includeValues","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"origin","required":true,"type":"string"},{"name":"stores","required":true,"type":"array"}],"name":"StorageList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"visual.compare","parameters":[{"maximumBytes":128,"name":"before","required":true,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"after","required":true,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"changedPixels","required":false,"type":"number"},{"name":"differenceRatio","required":false,"type":"number"}],"name":"VisualComparison","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"performance.get","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"timing","required":true,"type":"json"},{"name":"webVitals","required":true,"type":"object"},{"name":"resources","required":true,"type":"object"}],"name":"Performance","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"animation.list","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"count","required":true,"type":"number"},{"name":"animations","required":true,"type":"array"},{"name":"truncated","required":true,"type":"boolean"}],"name":"AnimationList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"report.create","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"path","required":true,"type":"string"},{"name":"kind","required":true,"type":"string"},{"name":"bytes","required":true,"type":"number"},{"name":"createdAt","required":true,"type":"number"}],"name":"Artifact","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.start","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"recording","required":true,"type":"boolean"},{"name":"note","required":true,"type":"string"}],"name":"FlowStart","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.stop","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"path","required":true,"type":"string"},{"name":"kind","required":true,"type":"string"},{"name":"bytes","required":true,"type":"number"},{"name":"createdAt","required":true,"type":"number"}],"name":"Artifact","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.run","parameters":[{"maximumBytes":128,"name":"input","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"completed","required":true,"type":"number"},{"name":"input","required":true,"type":"string"}],"name":"FlowRun","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":125000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.emulate","parameters":[{"name":"offline","required":false,"sensitive":false,"type":"boolean"},{"maximum":120000,"minimum":0,"name":"latencyMs","required":false,"sensitive":false,"type":"number"},{"maximum":1000000,"minimum":-1,"name":"downloadKbps","required":false,"sensitive":false,"type":"number"},{"maximum":1000000,"minimum":-1,"name":"uploadKbps","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"offline","required":true,"type":"boolean"},{"name":"latencyMs","required":true,"type":"number"},{"name":"downloadKbps","required":true,"type":"number"},{"name":"uploadKbps","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"}],"name":"NetworkEmulation","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.mock.set","parameters":[{"maximumBytes":8192,"name":"url","required":true,"sensitive":false,"type":"string"},{"maximum":599,"minimum":100,"name":"status","required":false,"sensitive":false,"type":"number"},{"maximumBytes":65536,"name":"body","required":true,"sensitive":false,"type":"string"},{"maximumBytes":256,"name":"contentType","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"status","required":true,"type":"number"},{"name":"activeMocks","required":true,"type":"number"}],"name":"NetworkMock","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.mock.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"number"}],"name":"NetworkMockClear","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["choose interactive login or account alias","saved login requires a valid single-use challenge"],"name":"auth.login","parameters":[{"maximumBytes":64,"name":"challenge","required":false,"sensitive":false,"type":"string"},{"maximumBytes":64,"name":"account","required":false,"sensitive":false,"type":"string"},{"name":"interactive","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"origin","required":true,"type":"string"},{"name":"account","required":true,"type":"string-or-null"},{"name":"saved","required":true,"type":"boolean"},{"name":"continuation","required":true,"type":"string"},{"name":"passwordExposed","required":true,"type":"boolean"},{"name":"originalActionReplayed","required":true,"type":"boolean"}],"name":"AuthenticationLogin","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}}],"compatibility":{"mismatch":"host rejects request or client rejects response before decoding result","olderClient":"may call only commands and parameters declared by its bundled schema","olderHost":"missing commands or capabilities fail explicitly; clients must not emulate them","policy":"exact-wire-version","productVersionIndependent":true},"errorDetails":{"AUTH_REQUIRED":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"challenge","required":true,"type":"string"},{"name":"origin","required":true,"type":"string"},{"name":"detection","required":true,"type":"string"},{"name":"accounts","required":true,"type":"array"},{"name":"expiresInSeconds","required":true,"type":"number"},{"name":"userPresenceRequired","required":true,"type":"boolean"},{"name":"credentialUseAvailable","required":true,"type":"boolean"},{"name":"vaultAvailable","required":true,"type":"boolean"},{"name":"vaultStatus","required":true,"type":"string"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"originalActionReplayed","required":true,"type":"boolean"}],"name":"AuthenticationRequired","type":"object"}}},"format":"headless-sdk-contract","hostLifecycle":{"connect":"attach to an existing host and never assume ownership","existingHost":"supervised start fails rather than attaching","ownerExit":"host shuts down when the owner pipe closes","sharedStart":"headless start","shutdown":"only explicit user requests may stop a shared host","supervisedOwnerChannel":"open standard-input pipe","supervisedStart":"headless start --supervised"},"jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema","localCommands":["capabilities","config.describe","config.get","config.list","config.reset","config.set","credentials.add","credentials.list","credentials.remove","credentials.rename","help","runtime","schema","start","version"],"localErrorCodes":["CONFIGURATION_FAILED","HOST_START_FAILED","HOST_UNAVAILABLE","INVALID_CONFIGURATION","NAVIGATION_ALLOWLIST_CONFLICT","UNSUPPORTED_BROWSER_RUNTIME","UNSUPPORTED_CAPABILITY","VAULT_UNAVAILABLE"],"localLifecycle":{"connect":{"errors":["HOST_UNAVAILABLE"],"ownership":"shared","transport":"local-unix-socket"},"launch":{"argv":["start","--supervised"],"command":"start","errors":["HOST_START_FAILED","NAVIGATION_ALLOWLIST_CONFLICT","UNSUPPORTED_BROWSER_RUNTIME","UNSUPPORTED_CAPABILITY"],"options":[{"name":"presentation","required":false,"type":"string","values":["background","foreground"]},{"itemMaximumBytes":300,"maximumItems":32,"name":"allow","required":false,"type":"string-array"},{"const":true,"name":"supervised","required":true,"type":"boolean"}],"ownership":"owned-only-after-response-pid-matches-launched-child","result":{"additionalProperties":true,"fields":[{"name":"ready","required":true,"type":"boolean"},{"name":"pid","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"},{"name":"platform","required":true,"type":"string"},{"name":"productVersion","required":true,"type":"string"},{"name":"protocolVersion","required":true,"type":"string"},{"name":"capabilities","required":true,"type":"object"},{"name":"recordingAvailable","required":true,"type":"boolean"},{"name":"artifactDirectory","required":true,"type":"string"},{"name":"navigationAllowlist","required":true,"type":"array"}],"name":"HostStatus","type":"object"}}},"maximumMessageBytes":1048576,"protocolVersion":"0.5","releasePolicy":{"preOneDeprecation":"at least one minor SDK release","provenance":"publish from reviewed tags with package attestations","schemaVersioning":"integer; generators reject unknown versions","sdkVersioning":"semantic versions independent of the wire version; combined product distributors track product tags","securityReporting":"SECURITY.md; never include secrets or private artifacts","stableRemoval":"major SDK release and migration notes"},"request":{"additionalProperties":false,"id":{"maximumBytes":128,"minimumBytes":1,"type":"string"},"parameters":{"additionalProperties":false,"type":"object"},"required":["id","version","command","parameters"],"session":{"maximumBytes":64,"type":["string","null"]},"type":"object","version":{"const":"0.5","type":"string"}},"response":{"additionalProperties":true,"failure":{"error":{"codes":["ARTIFACT_ERROR","AUTH_ACCOUNT_NOT_FOUND","AUTH_CHALLENGE_CONSUMED","AUTH_CHALLENGE_EXPIRED","AUTH_CHALLENGE_NOT_FOUND","AUTH_FORM_CHANGED","AUTH_ORIGIN_CHANGED","AUTH_REQUIRED","CREDENTIAL_ALIAS_EXISTS","ELEMENT_NOT_FOUND","FLOW_FAILED","HOST_STOPPING","HOST_UNAVAILABLE","INTERNAL_ERROR","INVALID_CAPTURE_FORMAT","INVALID_COMMAND","INVALID_FLOW","INVALID_INPUT","INVALID_REQUEST","INVALID_SESSION","MISSING_PARAMETER","OPERATION_FAILED","PAGINATION_CURSOR_EXPIRED","PAGINATION_CURSOR_INVALID","PAGINATION_CURSOR_SCOPE_MISMATCH","PAGINATION_CURSOR_STALE","PEER_DENIED","RECORDER_UNAVAILABLE","RECORDING_ACTIVE","RECORDING_FAILED","RECORDING_NOT_ACTIVE","REGION_NOT_FOUND","RESPONSE_TOO_LARGE","SENSITIVE_DIAGNOSTICS_DISABLED","SESSION_EXISTS","SESSION_NOT_FOUND","TIMEOUT","UNSAFE_NAVIGATION","UNSAFE_RESOURCE_TYPE","UNSUPPORTED_CAPABILITY","USER_PRESENCE_DENIED","USER_PRESENCE_UNAVAILABLE","VAULT_LOCKED","VAULT_OPERATION_FAILED","VAULT_RESPONSE_INVALID","VAULT_UNAVAILABLE"],"optional":["suggestion","details"],"required":["code","message"]},"ok":false,"result":null},"required":["id","version","ok"],"success":{"error":null,"ok":true,"result":"json"},"type":"object"},"schemaVersion":1,"security":{"arbitraryJavaScript":false,"credentialParameters":"aliases-and-challenge-identifiers-only","pageData":"untrusted","sensitiveDiagnostics":"requires-command-flag-and-host-environment-gate","tcpListener":false,"unknownRequestFields":"rejected","unknownResponseFields":"accepted for compatible additive evolution"},"supportWindow":{"hostPlatforms":["macOS 13 or newer","Linux amd64","Linux arm64"],"maintenance":"latest two minor SDK lines and at least 12 months after supersession, whichever is longer","pythonRuntimes":["CPython 3.11 through 3.14"],"schemaVersions":[1],"securityFixes":"supported SDK lines receive applicable security fixes","typescriptRuntimes":["Node.js 22 or newer"],"wireVersions":["0.5"]},"timeouts":{"connect":"fails before a request is sent","requestAfterWrite":"outcome unknown; never retry automatically","requestBeforeWrite":"safe to retry with a new request identifier","startup":"terminate and reap only the supervised launcher and host"},"transport":{"framing":"one-newline-terminated-json-document-per-connection","kind":"local-unix-socket","peerAuthorization":"same-os-user","runtimeDirectoryMode":"0700","socketMode":"0600","tcp":false}} +{"cancellation":{"afterSend":"close the client connection; command outcome is unknown until state is inspected","beforeSend":"request is not written","ownedHost":"terminate, await, and reap only a host process launched by that SDK instance","sharedHost":"never stop the host implicitly"},"commands":[{"capabilityNegotiated":false,"constraints":[],"name":"ping","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"ready","required":true,"type":"boolean"},{"name":"pid","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"},{"name":"platform","required":true,"type":"string"},{"name":"productVersion","required":true,"type":"string"},{"name":"protocolVersion","required":true,"type":"string"},{"name":"capabilities","required":true,"type":"object"},{"name":"recordingAvailable","required":true,"type":"boolean"},{"name":"artifactDirectory","required":true,"type":"string"},{"name":"navigationAllowlist","required":true,"type":"array"}],"name":"HostStatus","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"shutdown","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"stopping","required":true,"type":"boolean"}],"name":"Shutdown","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"profile.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"boolean"},{"name":"session","required":true,"type":"string"}],"name":"ProfileClear","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.create","parameters":[{"maximumBytes":64,"name":"name","required":true,"sensitive":false,"type":"string"},{"name":"isolated","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"session","required":true,"type":"string"},{"name":"isolated","required":true,"type":"boolean"}],"name":"SessionCreate","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.list","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"items":{"type":"string"},"name":"sessions","required":true,"type":"array"},{"items":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"isolated","required":true,"type":"boolean"},{"name":"ageMs","required":true,"type":"number"},{"name":"status","required":true,"type":"string","values":["available","navigating","unavailable"]},{"name":"url","required":true,"type":"string-or-null"},{"name":"title","required":true,"type":"string-or-null"},{"name":"urlTruncated","required":true,"type":"boolean"},{"name":"titleTruncated","required":true,"type":"boolean"},{"name":"untrustedContent","required":true,"type":"boolean"}],"name":"SessionDetail","type":"object"},"name":"details","required":true,"type":"array"}],"name":"SessionList","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"session.close","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"closed","required":true,"type":"string"}],"name":"SessionClose","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"visit","parameters":[{"maximumBytes":8192,"name":"url","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"inspect","parameters":[{"name":"interactive","required":false,"sensitive":false,"type":"boolean"},{"name":"text","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":16,"name":"context","required":false,"sensitive":false,"type":"string","values":["summary","outline","text","actions","full"]},{"maximumBytes":512,"name":"task","required":false,"sensitive":false,"type":"string"},{"maximumBytes":16,"name":"within","required":false,"sensitive":false,"type":"string"},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximum":16000,"minimum":256,"name":"budget","required":false,"sensitive":false,"type":"integer"},{"maximum":8,"minimum":0,"name":"depth","required":false,"sensitive":false,"type":"integer"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"contextMode","required":true,"type":"string"},{"name":"viewport","required":true,"type":"object"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"elements","required":false,"type":"array"},{"name":"regions","required":false,"type":"array"},{"name":"snippets","required":false,"type":"array"},{"name":"text","required":false,"type":"string"}],"name":"Inspection","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"click","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"clicked","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"}],"name":"Click","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"fill","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":900000,"name":"value","required":true,"sensitive":true,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"filled","required":true,"type":"string"},{"name":"valueLength","required":true,"type":"number"}],"name":"Fill","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target","exactly one option label or value","native single-selection HTML select controls only"],"name":"select","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"label","required":false,"sensitive":true,"type":"string"},{"maximumBytes":1000,"name":"value","required":false,"sensitive":true,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"selected","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"optionIndex","required":true,"type":"number"}],"name":"Select","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["artifact must be an existing private-store basename","exactly one target reference or semantic role/name target"],"name":"upload","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"artifact","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"uploaded","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"artifact","required":true,"type":"string"}],"name":"Upload","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"press","parameters":[{"maximumBytes":32,"name":"key","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"pressed","required":true,"type":"string"}],"name":"Press","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"scroll","parameters":[{"maximumBytes":8192,"name":"direction","required":false,"sensitive":false,"type":"string","values":["up","down","top","bottom"]},{"maximum":100000,"minimum":0.1,"name":"amount","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"direction","required":true,"type":"string"},{"name":"amount","required":true,"type":"number"}],"name":"Scroll","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"back","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"reload","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"wait","parameters":[{"name":"settled","required":false,"sensitive":false,"type":"boolean"},{"name":"networkIdle","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":8192,"name":"url","required":false,"sensitive":false,"type":"string"},{"maximumBytes":30000,"name":"text","required":false,"sensitive":false,"type":"string"},{"maximum":120000,"minimum":100,"name":"timeoutMs","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"title","required":true,"type":"string"},{"name":"readyState","required":true,"type":"string"},{"name":"text","required":true,"type":"string"},{"name":"runningAnimations","required":true,"type":"number"},{"name":"mutationQuietMs","required":true,"type":"number"},{"name":"scrollY","required":true,"type":"number"},{"name":"contentHeight","required":true,"type":"number"}],"name":"PageState","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"maximumMilliseconds":125000,"minimumMilliseconds":10000,"parameterGraceMilliseconds":5000,"parameterName":"timeoutMs","parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"tour","parameters":[{"name":"fullPage","required":false,"sensitive":false,"type":"boolean"},{"maximum":5000,"minimum":100,"name":"pace","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"start","required":true,"type":"number"},{"name":"end","required":true,"type":"number"},{"name":"durationMs","required":true,"type":"number"}],"name":"Tour","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":125000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"capture.info","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"engine","required":true,"type":"string"},{"name":"page","required":true,"type":"object"},{"name":"trace","required":true,"type":"array"},{"name":"recording","required":true,"type":"object"}],"name":"CaptureInfo","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["target, full-page, and series modes are mutually constrained","region is required only for region series and must be an @rN reference","PDF requires full-page mode and no clipboard"],"name":"screenshot","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"name":"fullPage","required":false,"sensitive":false,"type":"boolean"},{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"},{"maximumBytes":32,"name":"series","required":false,"sensitive":false,"type":"string","values":["viewport","section","region"]},{"maximumBytes":16,"name":"region","required":false,"sensitive":false,"type":"string"},{"maximumBytes":80,"name":"outputPrefix","required":false,"sensitive":false,"type":"string"},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"format","required":false,"sensitive":false,"type":"string","values":["png","jpg","jpeg","pdf"]},{"name":"clipboard","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":false,"type":"string"},{"name":"path","required":false,"type":"string"},{"name":"kind","required":false,"type":"string"},{"name":"bytes","required":false,"type":"number"},{"name":"createdAt","required":false,"type":"number"},{"name":"artifacts","required":false,"type":"array"},{"name":"series","required":false,"type":"string","values":["viewport","section","region"]},{"name":"count","required":false,"type":"number"},{"name":"positions","required":false,"type":"array"},{"name":"truncated","required":false,"type":"boolean"},{"name":"totalPoints","required":false,"type":"number"},{"name":"untrustedContent","required":false,"type":"boolean"}],"name":"Screenshot","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":30000,"parameterPresentOverrides":{"series":125000}}},{"capabilityNegotiated":false,"constraints":[],"name":"artifact.list","parameters":[{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"directory","required":true,"type":"string"},{"name":"artifacts","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"total","required":true,"type":"number"},{"name":"omitted","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"ArtifactList","type":"object"}},"scope":"host","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.start","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"},{"maximum":30,"minimum":1,"name":"fps","required":false,"sensitive":false,"type":"number"},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"format","required":false,"sensitive":false,"type":"string","values":["mp4","mov","webm","gif"]},{"caseInsensitiveValues":true,"maximumBytes":16,"name":"quality","required":false,"sensitive":false,"type":"string","values":["fast","balanced","high"]}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.status","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"record.stop","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"active","required":true,"type":"boolean"},{"name":"format","required":false,"type":"string"},{"name":"quality","required":false,"type":"string"},{"name":"name","required":false,"type":"string"}],"name":"Recording","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":30000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"qa.report","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"summary","required":true,"type":"object"},{"name":"issues","required":true,"type":"array"},{"name":"events","required":true,"type":"array"},{"name":"omitted","required":true,"type":"object"},{"name":"truncated","required":true,"type":"boolean"}],"name":"QAReport","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"qa.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"number"}],"name":"QAClear","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"console.list","parameters":[{"maximumBytes":16,"name":"level","required":false,"sensitive":false,"type":"string","values":["all","log","info","debug","warn","error","assert"]},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"messages","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"ConsoleList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"network.list","parameters":[{"name":"failed","required":false,"sensitive":false,"type":"boolean"},{"maximum":599,"minimum":100,"name":"status","required":false,"sensitive":false,"type":"number"},{"maximum":250,"minimum":1,"name":"limit","required":false,"sensitive":false,"type":"integer"},{"maximumBytes":64,"name":"cursor","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"requests","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"},{"name":"nextCursor","required":true,"type":"string-or-null"},{"name":"mutation","required":true,"type":"string","values":["none"]}],"name":"NetworkList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"network.get","parameters":[{"maximumBytes":128,"name":"requestId","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"found","required":true,"type":"boolean"},{"name":"requestId","required":false,"type":"string"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"request","required":false,"type":"object"}],"name":"NetworkDetail","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["exactly one target reference or semantic role/name target"],"name":"styles.get","parameters":[{"maximumBytes":16,"name":"target","required":false,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"role","required":false,"sensitive":false,"type":"string"},{"maximumBytes":1000,"name":"name","required":false,"sensitive":false,"type":"string"},{"itemMaximumBytes":128,"maximumItems":64,"name":"properties","required":false,"sensitive":false,"type":"string-array"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"ref","required":true,"type":"string"},{"name":"role","required":true,"type":"string"},{"name":"name","required":true,"type":"string"},{"name":"box","required":true,"type":"object"},{"name":"styles","required":true,"type":"object"}],"name":"Styles","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["values require the sensitive diagnostics environment gate"],"name":"cookies.list","parameters":[{"name":"includeValues","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"cookies","required":true,"type":"array"},{"name":"returned","required":true,"type":"number"},{"name":"available","required":true,"type":"number"},{"name":"truncated","required":true,"type":"boolean"}],"name":"CookieList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":["values require the sensitive diagnostics environment gate"],"name":"storage.list","parameters":[{"maximumBytes":16,"name":"scope","required":false,"sensitive":false,"type":"string","values":["local","session","all"]},{"name":"includeValues","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"origin","required":true,"type":"string"},{"name":"stores","required":true,"type":"array"}],"name":"StorageList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"visual.compare","parameters":[{"maximumBytes":128,"name":"before","required":true,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"after","required":true,"sensitive":false,"type":"string"},{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"changedPixels","required":false,"type":"number"},{"name":"differenceRatio","required":false,"type":"number"}],"name":"VisualComparison","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"performance.get","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"timing","required":true,"type":"json"},{"name":"webVitals","required":true,"type":"object"},{"name":"resources","required":true,"type":"object"}],"name":"Performance","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"animation.list","parameters":[],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"count","required":true,"type":"number"},{"name":"animations","required":true,"type":"array"},{"name":"truncated","required":true,"type":"boolean"}],"name":"AnimationList","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"report.create","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"path","required":true,"type":"string"},{"name":"kind","required":true,"type":"string"},{"name":"bytes","required":true,"type":"number"},{"name":"createdAt","required":true,"type":"number"}],"name":"Artifact","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.start","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"recording","required":true,"type":"boolean"},{"name":"note","required":true,"type":"string"}],"name":"FlowStart","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.stop","parameters":[{"maximumBytes":128,"name":"output","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"name","required":true,"type":"string"},{"name":"path","required":true,"type":"string"},{"name":"kind","required":true,"type":"string"},{"name":"bytes","required":true,"type":"number"},{"name":"createdAt","required":true,"type":"number"}],"name":"Artifact","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":false,"constraints":[],"name":"flow.run","parameters":[{"maximumBytes":128,"name":"input","required":true,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"completed","required":true,"type":"number"},{"name":"input","required":true,"type":"string"}],"name":"FlowRun","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":125000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.emulate","parameters":[{"name":"offline","required":false,"sensitive":false,"type":"boolean"},{"maximum":120000,"minimum":0,"name":"latencyMs","required":false,"sensitive":false,"type":"number"},{"maximum":1000000,"minimum":-1,"name":"downloadKbps","required":false,"sensitive":false,"type":"number"},{"maximum":1000000,"minimum":-1,"name":"uploadKbps","required":false,"sensitive":false,"type":"number"}],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"offline","required":true,"type":"boolean"},{"name":"latencyMs","required":true,"type":"number"},{"name":"downloadKbps","required":true,"type":"number"},{"name":"uploadKbps","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"}],"name":"NetworkEmulation","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.mock.set","parameters":[{"maximumBytes":8192,"name":"url","required":true,"sensitive":false,"type":"string"},{"maximum":599,"minimum":100,"name":"status","required":false,"sensitive":false,"type":"number"},{"maximumBytes":65536,"name":"body","required":true,"sensitive":false,"type":"string"},{"maximumBytes":256,"name":"contentType","required":false,"sensitive":false,"type":"string"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"url","required":true,"type":"string"},{"name":"status","required":true,"type":"number"},{"name":"activeMocks","required":true,"type":"number"}],"name":"NetworkMock","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":[],"name":"network.mock.clear","parameters":[],"result":{"mayContainUntrustedContent":false,"schema":{"additionalProperties":true,"fields":[{"name":"cleared","required":true,"type":"number"}],"name":"NetworkMockClear","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}},{"capabilityNegotiated":true,"constraints":["choose interactive login or account alias","saved login requires a valid single-use challenge"],"name":"auth.login","parameters":[{"maximumBytes":64,"name":"challenge","required":false,"sensitive":false,"type":"string"},{"maximumBytes":64,"name":"account","required":false,"sensitive":false,"type":"string"},{"name":"interactive","required":false,"sensitive":false,"type":"boolean"}],"result":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"origin","required":true,"type":"string"},{"name":"account","required":true,"type":"string-or-null"},{"name":"saved","required":true,"type":"boolean"},{"name":"continuation","required":true,"type":"string"},{"name":"passwordExposed","required":true,"type":"boolean"},{"name":"originalActionReplayed","required":true,"type":"boolean"}],"name":"AuthenticationLogin","type":"object"}},"scope":"session","timeout":{"defaultMilliseconds":15000,"parameterPresentOverrides":{}}}],"compatibility":{"mismatch":"host rejects request or client rejects response before decoding result","olderClient":"may call only commands and parameters declared by its bundled schema","olderHost":"missing commands or capabilities fail explicitly; clients must not emulate them","policy":"exact-wire-version","productVersionIndependent":true},"errorDetails":{"AUTH_REQUIRED":{"mayContainUntrustedContent":true,"schema":{"additionalProperties":true,"fields":[{"name":"challenge","required":true,"type":"string"},{"name":"origin","required":true,"type":"string"},{"name":"detection","required":true,"type":"string"},{"name":"accounts","required":true,"type":"array"},{"name":"expiresInSeconds","required":true,"type":"number"},{"name":"userPresenceRequired","required":true,"type":"boolean"},{"name":"credentialUseAvailable","required":true,"type":"boolean"},{"name":"vaultAvailable","required":true,"type":"boolean"},{"name":"vaultStatus","required":true,"type":"string"},{"name":"untrustedContent","required":true,"type":"boolean"},{"name":"originalActionReplayed","required":true,"type":"boolean"}],"name":"AuthenticationRequired","type":"object"}}},"format":"headless-sdk-contract","hostLifecycle":{"connect":"attach to an existing host and never assume ownership","existingHost":"supervised start fails rather than attaching","ownerExit":"host shuts down when the owner pipe closes","sharedStart":"headless start","shutdown":"only explicit user requests may stop a shared host","supervisedOwnerChannel":"open standard-input pipe","supervisedStart":"headless start --supervised"},"jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema","localCommands":["capabilities","config.describe","config.get","config.list","config.reset","config.set","credentials.add","credentials.list","credentials.remove","credentials.rename","help","runtime","schema","start","version"],"localErrorCodes":["CONFIGURATION_FAILED","HOST_START_FAILED","HOST_UNAVAILABLE","INVALID_CONFIGURATION","NAVIGATION_ALLOWLIST_CONFLICT","UNSUPPORTED_BROWSER_RUNTIME","UNSUPPORTED_CAPABILITY","VAULT_UNAVAILABLE"],"localLifecycle":{"connect":{"errors":["HOST_UNAVAILABLE"],"ownership":"shared","transport":"local-unix-socket"},"launch":{"argv":["start","--supervised"],"command":"start","errors":["HOST_START_FAILED","NAVIGATION_ALLOWLIST_CONFLICT","UNSUPPORTED_BROWSER_RUNTIME","UNSUPPORTED_CAPABILITY"],"options":[{"name":"presentation","required":false,"type":"string","values":["background","foreground"]},{"itemMaximumBytes":300,"maximumItems":32,"name":"allow","required":false,"type":"string-array"},{"const":true,"name":"supervised","required":true,"type":"boolean"}],"ownership":"owned-only-after-response-pid-matches-launched-child","result":{"additionalProperties":true,"fields":[{"name":"ready","required":true,"type":"boolean"},{"name":"pid","required":true,"type":"number"},{"name":"engine","required":true,"type":"string"},{"name":"platform","required":true,"type":"string"},{"name":"productVersion","required":true,"type":"string"},{"name":"protocolVersion","required":true,"type":"string"},{"name":"capabilities","required":true,"type":"object"},{"name":"recordingAvailable","required":true,"type":"boolean"},{"name":"artifactDirectory","required":true,"type":"string"},{"name":"navigationAllowlist","required":true,"type":"array"}],"name":"HostStatus","type":"object"}}},"maximumMessageBytes":1048576,"protocolVersion":"0.5","releasePolicy":{"preOneDeprecation":"at least one minor SDK release","provenance":"publish from reviewed tags with package attestations","schemaVersioning":"integer; generators reject unknown versions","sdkVersioning":"semantic versions independent of the wire version; combined product distributors track product tags","securityReporting":"SECURITY.md; never include secrets or private artifacts","stableRemoval":"major SDK release and migration notes"},"request":{"additionalProperties":false,"id":{"maximumBytes":128,"minimumBytes":1,"type":"string"},"parameters":{"additionalProperties":false,"type":"object"},"required":["id","version","command","parameters"],"session":{"maximumBytes":64,"type":["string","null"]},"type":"object","version":{"const":"0.5","type":"string"}},"response":{"additionalProperties":true,"failure":{"error":{"codes":["ARTIFACT_ERROR","AUTH_ACCOUNT_NOT_FOUND","AUTH_CHALLENGE_CONSUMED","AUTH_CHALLENGE_EXPIRED","AUTH_CHALLENGE_NOT_FOUND","AUTH_FORM_CHANGED","AUTH_ORIGIN_CHANGED","AUTH_REQUIRED","CREDENTIAL_ALIAS_EXISTS","ELEMENT_NOT_FOUND","FLOW_FAILED","HOST_STOPPING","HOST_UNAVAILABLE","INTERNAL_ERROR","INVALID_CAPTURE_FORMAT","INVALID_COMMAND","INVALID_FLOW","INVALID_INPUT","INVALID_REQUEST","INVALID_SESSION","MISSING_PARAMETER","OPERATION_FAILED","PAGINATION_CURSOR_EXPIRED","PAGINATION_CURSOR_INVALID","PAGINATION_CURSOR_SCOPE_MISMATCH","PAGINATION_CURSOR_STALE","PEER_DENIED","RECORDER_UNAVAILABLE","RECORDING_ACTIVE","RECORDING_FAILED","RECORDING_NOT_ACTIVE","REGION_NOT_FOUND","RESPONSE_TOO_LARGE","SENSITIVE_DIAGNOSTICS_DISABLED","SESSION_EXISTS","SESSION_NOT_FOUND","TIMEOUT","UNSAFE_NAVIGATION","UNSAFE_RESOURCE_TYPE","UNSUPPORTED_CAPABILITY","USER_PRESENCE_DENIED","USER_PRESENCE_UNAVAILABLE","VAULT_LOCKED","VAULT_OPERATION_FAILED","VAULT_RESPONSE_INVALID","VAULT_UNAVAILABLE"],"optional":["suggestion","details"],"required":["code","message"]},"ok":false,"result":null},"required":["id","version","ok"],"success":{"error":null,"ok":true,"result":"json"},"type":"object"},"schemaVersion":1,"security":{"arbitraryJavaScript":false,"credentialParameters":"aliases-and-challenge-identifiers-only","pageData":"untrusted","sensitiveDiagnostics":"requires-command-flag-and-host-environment-gate","tcpListener":false,"unknownRequestFields":"rejected","unknownResponseFields":"accepted for compatible additive evolution"},"supportWindow":{"hostPlatforms":["macOS 13 or newer","Linux amd64","Linux arm64"],"maintenance":"latest two minor SDK lines and at least 12 months after supersession, whichever is longer","pythonRuntimes":["CPython 3.11 through 3.14"],"schemaVersions":[1],"securityFixes":"supported SDK lines receive applicable security fixes","typescriptRuntimes":["Node.js 22 or newer"],"wireVersions":["0.5"]},"timeouts":{"connect":"fails before a request is sent","requestAfterWrite":"outcome unknown; never retry automatically","requestBeforeWrite":"safe to retry with a new request identifier","startup":"terminate and reap only the supervised launcher and host"},"transport":{"framing":"one-newline-terminated-json-document-per-connection","kind":"local-unix-socket","peerAuthorization":"same-os-user","runtimeDirectoryMode":"0700","socketMode":"0600","tcp":false}}