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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions apps/headless/Host/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,12 +321,16 @@ extension BrowserWindowController {
func agentQAReport() -> JSONValue { qaBridge.store.report() }
func agentQAClear() -> JSONValue { qaBridge.clear() }

func agentConsole(level: String, limit: Int) -> JSONValue {
qaBridge.store.console(level: level, limit: limit)
func agentConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
try qaBridge.store.console(level: level, limit: limit, cursor: cursor)
}

func agentNetwork(failedOnly: Bool, status: Int?, limit: Int) -> JSONValue {
qaBridge.store.network(failedOnly: failedOnly, status: status, limit: limit)
func agentNetwork(
failedOnly: Bool, status: Int?, limit: Int, cursor: String?
) throws -> JSONValue {
try qaBridge.store.network(
failedOnly: failedOnly, status: status, limit: limit, cursor: cursor
)
}

func agentNetworkDetail(requestID: String) -> JSONValue {
Expand Down Expand Up @@ -582,11 +586,11 @@ extension BrowserWindowController: BrowserEngineSession {
}
func hostQAReport() throws -> JSONValue { agentQAReport() }
func hostQAClear() throws -> JSONValue { agentQAClear() }
func hostConsole(level: String, limit: Int) throws -> JSONValue {
agentConsole(level: level, limit: limit)
func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
try agentConsole(level: level, limit: limit, cursor: cursor)
}
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue {
agentNetwork(failedOnly: failedOnly, status: status, limit: limit)
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int, cursor: String?) throws -> JSONValue {
try agentNetwork(failedOnly: failedOnly, status: status, limit: limit, cursor: cursor)
}
func hostNetworkDetail(requestID: String) throws -> JSONValue {
agentNetworkDetail(requestID: requestID)
Expand Down
12 changes: 8 additions & 4 deletions apps/headless/LinuxHost/BrowserProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -984,12 +984,16 @@ final class LinuxBrowserSession: @unchecked Sendable {
return diagnostics.report()
}

func console(level: String, limit: Int) -> JSONValue {
diagnostics.console(level: level, limit: limit)
func console(level: String, limit: Int, cursor: String?) throws -> JSONValue {
try diagnostics.console(level: level, limit: limit, cursor: cursor)
}

func network(failedOnly: Bool, status: Int?, limit: Int) -> JSONValue {
diagnostics.network(failedOnly: failedOnly, status: status, limit: limit)
func network(
failedOnly: Bool, status: Int?, limit: Int, cursor: String?
) throws -> JSONValue {
try diagnostics.network(
failedOnly: failedOnly, status: status, limit: limit, cursor: cursor
)
}

func networkDetail(requestID: String) -> JSONValue {
Expand Down
8 changes: 4 additions & 4 deletions apps/headless/LinuxHost/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,11 @@ final class ChromiumBrowserEngineSession: BrowserEngineSession {
}
func hostQAReport() throws -> JSONValue { try browserSession.qaReport() }
func hostQAClear() throws -> JSONValue { browserSession.diagnostics.clear() }
func hostConsole(level: String, limit: Int) throws -> JSONValue {
browserSession.console(level: level, limit: limit)
func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue {
try browserSession.console(level: level, limit: limit, cursor: cursor)
}
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue {
browserSession.network(failedOnly: failedOnly, status: status, limit: limit)
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int, cursor: String?) throws -> JSONValue {
try browserSession.network(failedOnly: failedOnly, status: status, limit: limit, cursor: cursor)
}
func hostNetworkDetail(requestID: String) throws -> JSONValue {
browserSession.networkDetail(requestID: requestID)
Expand Down
33 changes: 21 additions & 12 deletions apps/headless/Sources/HeadlessProtocol/Artifacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public enum ArtifactError: Error, CustomStringConvertible {
public final class ArtifactStore: @unchecked Sendable {
public let rootURL: URL
private let lock = NSLock()
private let pagination = PaginationCursorStore()

public init(environment: [String: String] = ProcessInfo.processInfo.environment) throws {
rootURL = try Self.resolvedRootURL(environment: environment, platform: .current)
Expand Down Expand Up @@ -179,7 +180,7 @@ public final class ArtifactStore: @unchecked Sendable {
return try metadata(for: finalURL)
}

public func list() throws -> JSONValue {
public func list(limit: Int = 250, cursor: String? = nil) throws -> JSONValue {
lock.lock(); defer { lock.unlock() }
let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .creationDateKey]
let urls = try FileManager.default.contentsOfDirectory(
Expand All @@ -192,23 +193,27 @@ public final class ArtifactStore: @unchecked Sendable {
return try metadata(for: url)
}.sorted { left, right in
guard case .object(let lhs) = left, case .object(let rhs) = right else { return false }
return (lhs["createdAt"]?.numberValue ?? 0) > (rhs["createdAt"]?.numberValue ?? 0)
let leftCreated = lhs["createdAt"]?.numberValue ?? 0
let rightCreated = rhs["createdAt"]?.numberValue ?? 0
if leftCreated != rightCreated { return leftCreated > rightCreated }
return (lhs["name"]?.stringValue ?? "") < (rhs["name"]?.stringValue ?? "")
}
// The store grows without bound across a long session, and the listing
// has to survive the 1 MiB protocol frame. Newest first, bounded, and
// explicit about what was left out.
let listed = Array(artifacts.prefix(Self.maximumListedArtifacts))
let page = try pagination.page(
values: artifacts, context: "artifact.list", limit: limit,
cursor: cursor, direction: .fromStart
)
return .object([
"directory": .string(rootURL.path),
"artifacts": .array(listed),
"artifacts": .array(page.values),
"returned": .number(Double(page.values.count)),
"total": .number(Double(artifacts.count)),
"omitted": .number(Double(artifacts.count - listed.count)),
"truncated": .bool(listed.count < artifacts.count),
"omitted": .number(Double(artifacts.count - page.consumed)),
"truncated": .bool(page.truncated),
"nextCursor": page.nextCursor.map(JSONValue.string) ?? .null,
"mutation": .string("none"),
])
}

private static let maximumListedArtifacts = 250

private static let listedExtensions: Set<String> =
ScreenshotFormat.artifactExtensions
.union(RecordingFormat.artifactExtensions)
Expand All @@ -217,7 +222,11 @@ public final class ArtifactStore: @unchecked Sendable {
private func metadata(for url: URL) throws -> JSONValue {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
let bytes = (attributes[.size] as? NSNumber)?.doubleValue ?? 0
let created = (attributes[.creationDate] as? Date ?? Date()).timeIntervalSince1970
let created = (
(attributes[.creationDate] as? Date)
?? (attributes[.modificationDate] as? Date)
?? Date(timeIntervalSince1970: 0)
).timeIntervalSince1970
return .object([
"name": .string(url.lastPathComponent),
"path": .string(url.path),
Expand Down
34 changes: 25 additions & 9 deletions apps/headless/Sources/HeadlessProtocol/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,17 @@ public struct CLIParser {
case "screenshot":
return try parseScreenshot(arguments, session: session, jsonOutput: jsonOutput)
case "artifacts":
guard arguments == ["list"] else { throw CLIParseError.missingArgument("artifacts list") }
return remote(.artifactList, session: session, jsonOutput: jsonOutput)
guard arguments.first == "list" else { throw CLIParseError.missingArgument("artifacts list") }
var args = Array(arguments.dropFirst())
let limit = try removeOption("--limit", from: &args)
let cursor = try removeOption("--cursor", from: &args)
try requireEmpty(args)
var parameters: [String: JSONValue] = [:]
if let limit { parameters["limit"] = .number(try paginationLimit(limit)) }
if let cursor { parameters["cursor"] = .string(cursor) }
return remote(
.artifactList, session: session, parameters: parameters, jsonOutput: jsonOutput
)
case "record":
return try parseRecord(arguments, session: session, jsonOutput: jsonOutput)
case "qa":
Expand Down Expand Up @@ -617,12 +626,14 @@ public struct CLIParser {
var args = Array(arguments.dropFirst())
let level = try removeOption("--level", from: &args) ?? "all"
let limit = try removeOption("--limit", from: &args)
let cursor = try removeOption("--cursor", from: &args)
try requireEmpty(args)
guard ["all", "log", "info", "debug", "warn", "error", "assert"].contains(level) else {
throw CLIParseError.invalidOption(level)
}
var parameters: [String: JSONValue] = ["level": .string(level)]
if let limit { parameters["limit"] = .number(try diagnosticLimit(limit)) }
if let limit { parameters["limit"] = .number(try paginationLimit(limit)) }
if let cursor { parameters["cursor"] = .string(cursor) }
return remote(.consoleList, session: session, parameters: parameters, jsonOutput: jsonOutput)
}

Expand All @@ -634,13 +645,15 @@ public struct CLIParser {
let failed = removeFlag("--failed", from: &args)
let status = try removeOption("--status", from: &args)
let limit = try removeOption("--limit", from: &args)
let cursor = try removeOption("--cursor", from: &args)
try requireEmpty(args)
var parameters: [String: JSONValue] = ["failed": .bool(failed)]
if let status {
guard let code = Double(status), code >= 100, code <= 599 else { throw CLIParseError.invalidNumber(status) }
parameters["status"] = .number(code)
}
if let limit { parameters["limit"] = .number(try diagnosticLimit(limit)) }
if let limit { parameters["limit"] = .number(try paginationLimit(limit)) }
if let cursor { parameters["cursor"] = .string(cursor) }
return remote(.networkList, session: session, parameters: parameters, jsonOutput: jsonOutput)
case "get":
guard args.count == 1 else { throw CLIParseError.missingArgument("network get REQUEST_ID") }
Expand Down Expand Up @@ -760,8 +773,11 @@ public struct CLIParser {
}
}

private func diagnosticLimit(_ value: String) throws -> Double {
guard let number = Double(value), number >= 1, number <= 200 else { throw CLIParseError.invalidNumber(value) }
private func paginationLimit(_ value: String) throws -> Double {
guard let number = Double(value), number.isFinite, number.rounded() == number,
number >= 1, number <= Double(PaginationCursorStore.maximumLimit) else {
throw CLIParseError.invalidNumber(value)
}
return number
}

Expand Down Expand Up @@ -845,12 +861,12 @@ Commands:
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]
artifacts list
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]
qa report | qa clear
console list [--level LEVEL] [--limit N]
network list [--failed] [--status CODE] [--limit N]
console list [--level LEVEL] [--limit N] [--cursor CURSOR]
network list [--failed] [--status CODE] [--limit N] [--cursor CURSOR]
network get REQUEST_ID
network emulate [--offline] [--latency MS] [--download-kbps N] [--upload-kbps N]
network mock set URL --body BODY [--status CODE] [--content-type MIME]
Expand Down
33 changes: 24 additions & 9 deletions apps/headless/Sources/HeadlessProtocol/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public func diagnosticStringHeaders(_ headers: [String: Any]?) -> [String: Strin

public final class QADiagnosticStore: @unchecked Sendable {
private let lock = NSLock()
private let pagination = PaginationCursorStore()
private var events: [JSONValue] = []
private var didTruncate = false
private let maximumEvents = 500
Expand Down Expand Up @@ -142,23 +143,30 @@ public final class QADiagnosticStore: @unchecked Sendable {
return data.count
}

public func console(level: String, limit: Int) -> JSONValue {
public func console(level: String, limit: Int, cursor: String? = nil) throws -> JSONValue {
lock.lock(); let snapshot = events; lock.unlock()
let items = snapshot.filter { event in
guard case .object(let object) = event, object["kind"] == .string("console") else { return false }
return level == "all" || object["level"] == .string(level)
}
let boundedLimit = max(1, min(limit, 200))
let boundedItems = Array(items.suffix(boundedLimit))
let page = try pagination.page(
values: items, context: "console.list|level=\(level)", limit: limit,
cursor: cursor, direction: .newestBatchFirst
)
return .object([
"untrustedContent": .bool(true),
"messages": .array(boundedItems),
"returned": .number(Double(boundedItems.count)),
"messages": .array(page.values),
"returned": .number(Double(page.values.count)),
"available": .number(Double(items.count)),
"truncated": .bool(page.truncated),
"nextCursor": page.nextCursor.map(JSONValue.string) ?? .null,
"mutation": .string("none"),
])
}

public func network(failedOnly: Bool, status: Int?, limit: Int) -> JSONValue {
public func network(
failedOnly: Bool, status: Int?, limit: Int, cursor: String? = nil
) throws -> JSONValue {
lock.lock(); let snapshot = events; lock.unlock()
let matching = snapshot.filter { event in
guard case .object(let object) = event,
Expand All @@ -169,12 +177,19 @@ public final class QADiagnosticStore: @unchecked Sendable {
if let status, Int(object["status"]?.numberValue ?? 0) != status { return false }
return true
}.map(networkSummary(_:))
let bounded = Array(matching.suffix(max(1, min(limit, 200))))
let context = "network.list|failed=\(failedOnly)|status=\(status.map(String.init) ?? "any")"
let page = try pagination.page(
values: matching, context: context, limit: limit,
cursor: cursor, direction: .newestBatchFirst
)
return .object([
"untrustedContent": .bool(true),
"requests": .array(bounded),
"returned": .number(Double(bounded.count)),
"requests": .array(page.values),
"returned": .number(Double(page.values.count)),
"available": .number(Double(matching.count)),
"truncated": .bool(page.truncated),
"nextCursor": page.nextCursor.map(JSONValue.string) ?? .null,
"mutation": .string("none"),
])
}

Expand Down
15 changes: 10 additions & 5 deletions apps/headless/Sources/HeadlessProtocol/HostCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ public protocol BrowserEngineSession: AnyObject {
func hostScrollToCapturePoint(y: Double) throws -> JSONValue
func hostQAReport() throws -> JSONValue
func hostQAClear() throws -> JSONValue
func hostConsole(level: String, limit: Int) throws -> JSONValue
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int) throws -> JSONValue
func hostConsole(level: String, limit: Int, cursor: String?) throws -> JSONValue
func hostNetwork(failedOnly: Bool, status: Int?, limit: Int, cursor: String?) throws -> JSONValue
func hostNetworkDetail(requestID: String) throws -> JSONValue
func hostStyles(parameters: [String: JSONValue]) throws -> JSONValue
func hostCookies(includeValues: Bool) throws -> JSONValue
Expand Down Expand Up @@ -265,7 +265,10 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
return try clearProfile(request)
}
if request.command == .artifactList {
return .success(id: request.id, result: try artifacts.list())
return .success(id: request.id, result: try artifacts.list(
limit: Int(request.parameters["limit"]?.numberValue ?? 250),
cursor: request.parameters["cursor"]?.stringValue
))
}
switch request.command {
case .sessionCreate:
Expand Down Expand Up @@ -606,13 +609,15 @@ public final class HostCore<Engine: BrowserEngine>: @unchecked Sendable {
case .consoleList:
return try session.hostConsole(
level: request.parameters["level"]?.stringValue ?? "all",
limit: Int(request.parameters["limit"]?.numberValue ?? 100)
limit: Int(request.parameters["limit"]?.numberValue ?? 100),
cursor: request.parameters["cursor"]?.stringValue
)
case .networkList:
return try session.hostNetwork(
failedOnly: request.parameters["failed"]?.boolValue ?? false,
status: request.parameters["status"]?.numberValue.map(Int.init),
limit: Int(request.parameters["limit"]?.numberValue ?? 100)
limit: Int(request.parameters["limit"]?.numberValue ?? 100),
cursor: request.parameters["cursor"]?.stringValue
)
case .networkGet:
guard let requestID = request.parameters["requestId"]?.stringValue else {
Expand Down
7 changes: 7 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/HostError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public enum HostErrorCode: String, CaseIterable, Sendable {
case invalidFlow = "INVALID_FLOW"
case flowFailed = "FLOW_FAILED"
case invalidCommand = "INVALID_COMMAND"
case paginationCursorInvalid = "PAGINATION_CURSOR_INVALID"
case paginationCursorExpired = "PAGINATION_CURSOR_EXPIRED"
case paginationCursorScopeMismatch = "PAGINATION_CURSOR_SCOPE_MISMATCH"
case paginationCursorStale = "PAGINATION_CURSOR_STALE"
case operationFailed = "OPERATION_FAILED"
}

Expand Down Expand Up @@ -46,6 +50,9 @@ public struct HostError: Error, CustomStringConvertible, Sendable {
return "Use an engine that declares support for this capability."
case .missingParameter, .invalidFlow, .flowFailed, .invalidCommand:
return nil
case .paginationCursorInvalid, .paginationCursorExpired,
.paginationCursorScopeMismatch, .paginationCursorStale:
return "Restart pagination without --cursor."
case .operationFailed:
return nil
}
Expand Down
Loading
Loading