diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml index 3ea8751d..70ecb617 100644 --- a/.github/workflows/docc.yml +++ b/.github/workflows/docc.yml @@ -58,11 +58,12 @@ jobs: sdk_path="$(xcrun --sdk iphonesimulator --show-sdk-path)" swift package --scratch-path .build/docc-generation \ --sdk "$sdk_path" \ - --triple arm64-apple-ios18.0-simulator \ + --triple arm64-apple-ios18.4-simulator \ --allow-writing-to-directory .build/docs \ generate-documentation \ --target WebInspectorUI \ --target WebInspectorDataKit \ + --target WebInspectorDataKitTesting \ --target WebInspectorProxyKit \ --target WebInspectorProxyKitTesting \ --disable-indexing \ diff --git a/ContractTests/Package.swift b/ContractTests/Package.swift index 5859fe6f..7a6bb182 100644 --- a/ContractTests/Package.swift +++ b/ContractTests/Package.swift @@ -11,8 +11,8 @@ let strictSwiftSettings: [SwiftSetting] = [ let package = Package( name: "WebInspectorKitContractTests", platforms: [ - .iOS(.v18), - .macOS(.v15), + .iOS("18.4"), + .macOS("15.4"), ], dependencies: [ .package(path: ".."), @@ -29,6 +29,7 @@ let package = Package( name: "WebInspectorConsumerContractTests", dependencies: [ .product(name: "WebInspectorDataKit", package: "WebInspectorKit"), + .product(name: "WebInspectorDataKitTesting", package: "WebInspectorKit"), .product(name: "WebInspectorProxyKit", package: "WebInspectorKit"), .product(name: "WebInspectorProxyKitTesting", package: "WebInspectorKit"), .product( diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift index 85f4ba5e..3c65815b 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/ContractTestSupport.swift @@ -5,102 +5,116 @@ import WebInspectorProxyKit import WebInspectorProxyKitTesting enum ContractTestSupport { - static func enqueueDataKitStartupReplies( - on backend: WebInspectorTestBackend, - document: DOM.Node = WebInspectorProxyTestFixtures.domDocument() - ) async { - await backend.enqueue((), for: "Inspector", method: "enable") - await backend.enqueue((), for: "Inspector", method: "initialized") - await backend.enqueue((), for: "Runtime", method: "enable") - await backend.enqueue((), for: "Network", method: "enable") - await backend.enqueue(document, for: "DOM", method: "getDocument") - await backend.enqueue((), for: "Console", method: "enable") + static func documentResult( + id: String = "document", + documentURL: String? = nil, + childNodeCount: Int = 0 + ) throws -> WebInspectorTestJSONObject { + var root: [String: Any] = [ + "nodeId": id, + "nodeType": 9, + "nodeName": "#document", + "localName": "", + "nodeValue": "", + "frameId": "main-frame", + "childNodeCount": childNodeCount, + ] + root["documentURL"] = documentURL + return try jsonObject(["root": root]) } - static func enqueueDataKitShutdownReplies(on backend: WebInspectorTestBackend) async { - await backend.enqueue((), for: "Console", method: "disable") - await backend.enqueue((), for: "Runtime", method: "disable") - await backend.enqueue((), for: "Network", method: "disable") - await backend.enqueue((), for: "Inspector", method: "disable") + static func setChildNodesParameters() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "parentId": "contract-document", + "nodes": [[ + "nodeId": "contract-element", + "nodeType": 1, + "nodeName": "MAIN", + "localName": "main", + "nodeValue": "", + "attributes": [ + "data-contract", "dom", + "data-second", "2", + ], + "childNodeCount": 0, + ]], + ]) } - @MainActor - static func startDataKitContext( - runtime: WebInspectorProxyTestRuntime, - document: DOM.Node = WebInspectorProxyTestFixtures.domDocument() - ) async throws -> (WebInspectorTarget, WebInspectorContainer, WebInspectorContext) { - let target = try await runtime.proxy.waitForCurrentPage() - await enqueueDataKitStartupReplies(on: runtime.backend, document: document) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForDataKitSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } - return (target, container, context) + static func requestWillBeSentParameters() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "requestId": "contract-request", + "request": [ + "url": "https://example.com/data.json", + "method": "GET", + "headers": ["Accept": "application/json"], + ], + "type": "Fetch", + "timestamp": 1, + ]) } - static func waitForDataKitSubscribers( - runtime: WebInspectorProxyTestRuntime, - target: WebInspectorTarget, - count: Int = 1 - ) async throws { - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: count) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: count) - try await runtime.backend.waitForSubscribers(domain: "CSS", target: target, count: count) - try await runtime.backend.waitForSubscribers(domain: "Network", target: target, count: count) - try await runtime.backend.waitForSubscribers(domain: "Console", target: target, count: count) - try await runtime.backend.waitForSubscribers(domain: "Runtime", target: target, count: count) + static func responseReceivedParameters() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "requestId": "contract-request", + "response": [ + "url": "https://example.com/data.json", + "status": 200, + "statusText": "OK", + "mimeType": "application/json", + "headers": ["Content-Type": "application/json"], + "source": "network", + ], + "type": "Fetch", + "timestamp": 2, + ]) } - static func emitFinishedRequest( - _ request: Network.Request, - target: WebInspectorTarget, - backend: WebInspectorTestBackend - ) async { - await backend.emit( - .requestWillBeSent( - id: request.id, - request: request, - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await backend.emit( - .responseReceived( - id: request.id, - response: Network.Response( - url: request.url, - status: 200, - statusText: "OK", - mimeType: "application/json", - headers: ["Content-Type": "application/json"], - source: Network.Source(rawValue: "network") - ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - await backend.emit( - .dataReceived(id: request.id, dataLength: 7, encodedDataLength: 4, timestamp: 3), - target: target - ) - await backend.emit( - .loadingFinished( - id: request.id, - timestamp: 4, - sourceMapURL: "data.json.map", - metrics: Network.Metrics( - networkProtocol: "h2", - remoteAddress: "203.0.113.30:443", - encodedDataLength: 4, - decodedBodyLength: 7 - ) - ), - target: target + static func loadingFinishedParameters() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "requestId": "contract-request", + "timestamp": 4, + "sourceMapURL": "data.json.map", + "metrics": [ + "protocol": "h2", + "remoteAddress": "203.0.113.30:443", + "responseBodyBytesReceived": 4, + "responseBodyDecodedSize": 7, + ], + ]) + } + + static func outerHTMLResult(_ html: String) throws -> WebInspectorTestJSONObject { + try jsonObject(["outerHTML": html]) + } + + static func responseBodyResult( + _ body: String, + base64Encoded: Bool + ) throws -> WebInspectorTestJSONObject { + try jsonObject([ + "body": body, + "base64Encoded": base64Encoded, + ]) + } + + static func evaluationResult() throws -> WebInspectorTestJSONObject { + try jsonObject([ + "result": [ + "objectId": "contract-evaluation", + "type": "string", + "description": "contract", + "value": "contract", + ], + ]) + } + + static func jsonObject(_ object: [String: Any]) throws -> WebInspectorTestJSONObject { + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] ) + return try WebInspectorTestJSONObject(data: data) } static func waitUntil( @@ -118,106 +132,102 @@ enum ContractTestSupport { await Task.yield() } } - - static func waitUntil( - timeout: Duration = .seconds(1), - isolation: isolated (any Actor)? = #isolation, - condition: () async -> Bool - ) async throws { - _ = isolation - let clock = ContinuousClock() - let deadline = clock.now + timeout - while await condition() == false { - if clock.now >= deadline { - throw TimedOut() - } - await Task.yield() - } - } - - static func value( - of task: Task, - timeout: Duration = .seconds(1) - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - await task.value - } - group.addTask { - try await Task.sleep(for: timeout) - throw TimedOut() - } - guard let value = try await group.next() else { - throw TimedOut() - } - group.cancelAll() - return value - } - } } struct TimedOut: Error {} actor ContractDataKitActor { - nonisolated let inspectorContainer: WebInspectorContainer + private struct EvaluationSnapshot: Sendable { + let isException: Bool + let kind: Runtime.Kind + let value: Runtime.JSONValue? + let description: String? + let canRequestProperties: Bool + } + + private struct BodySnapshot: Sendable { + let phase: NetworkBody.Phase + let text: String? + let isBase64Encoded: Bool + } private let runtime: WebInspectorProxyTestRuntime - private var context: WebInspectorContext? + private let context: WebInspectorModelContext + private var commands: [WebInspectorTestPeer.Command] - init(runtime: WebInspectorProxyTestRuntime, inspectorContainer: WebInspectorContainer? = nil) { + init(runtime: WebInspectorProxyTestRuntime) { self.runtime = runtime - let container = inspectorContainer ?? WebInspectorContainer(proxy: runtime.proxy) - self.inspectorContainer = container - context = nil + context = WebInspectorModelContext() + commands = [] } - @discardableResult func start( - document: DOM.Node = WebInspectorProxyTestFixtures.domDocument(), - expectedSubscriberCount: Int = 1 - ) async throws -> WebInspectorTarget { - let context = modelContext() - let target = try await runtime.proxy.waitForCurrentPage() - await ContractTestSupport.enqueueDataKitStartupReplies(on: runtime.backend, document: document) - context.start() - try await ContractTestSupport.waitForDataKitSubscribers( - runtime: runtime, - target: target, - count: expectedSubscriberCount - ) - try await ContractTestSupport.waitUntil { context.state == .attached } - return target + document: WebInspectorTestJSONObject? = nil + ) async throws { + let documentResult = try document ?? ContractTestSupport.documentResult() + let attachTask = Task { + try await context.attach(to: runtime.proxy, isolation: self) + } + var observedMethods: Set = [] + for _ in 0..<6 { + let command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + observedMethods.insert(command.method) + if command.method == "DOM.getDocument" { + try await runtime.peer.reply(to: command, with: documentResult) + } else { + try await runtime.peer.reply(to: command) + } + } + #expect(observedMethods == [ + "Page.enable", + "CSS.enable", + "Network.enable", + "Console.enable", + "Runtime.enable", + "DOM.getDocument", + ]) + try await attachTask.value + #expect(context.state == .attached) } - func assertPublicSurfaceIsUsable() async throws { - let context = modelContext() - let requestResults: WebInspectorFetchedResults = context.fetchedResults() - let consoleResults: WebInspectorFetchedResults = context.fetchedResults() - let sectionedRequests: WebInspectorFetchedResults = - context.fetchedResults(sectionBy: \.method) - let sectionedConsole: WebInspectorFetchedResults = - context.fetchedResults(sectionBy: \.level) - let requestController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - let consoleController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - - #expect(requestResults.items.isEmpty) - #expect(consoleResults.items.isEmpty) - #expect(sectionedRequests.sections.isEmpty) - #expect(sectionedConsole.sections.isEmpty) - #expect(requestController.snapshot.itemIDs.isEmpty) - #expect(consoleController.snapshot.itemIDs.isEmpty) - _ = requestController.transactions - _ = consoleController.transactions - #expect(context.state == .attached) + func observedCommands() -> [WebInspectorTestPeer.Command] { + commands + } - let root = try #require(context.rootNode) + func assertPublicSurfaceIsUsable() async throws { + let requests = try await context.networkRequests(matching: NetworkQuery( + search: " contract ", + resourceCategories: [.xhrFetch], + methods: ["GET"], + sort: .requestTimeAscending, + section: .method, + offset: 0, + limit: 10 + )) + let messages = try await context.consoleMessages(matching: ConsoleQuery( + levels: [Console.Level(rawValue: "warning")], + sort: .insertionDescending, + section: .level, + offset: 0, + limit: 10 + )) + #expect(requests.items.isEmpty) + #expect(messages.items.isEmpty) + #expect(requests.snapshot.itemIDs.isEmpty) + #expect(messages.snapshot.itemIDs.isEmpty) + _ = requests.updates() + _ = messages.updates() + try await requests.update(NetworkQuery(sort: .requestTimeDescending)) + try await messages.update(ConsoleQuery(sort: .insertionAscending)) + + let root = try #require(try context.rootDOMNode) #expect(root.nodeName == "#document") - #expect(context.node(for: root.id) === root) + #expect(try context.domNode(id: root.id) === root) requirePersistentModel(root) - let treeController = try await context.treeController() + let treeController = try context.domTree let treeSnapshot: DOMTreeSnapshot = treeController.snapshot #expect(treeSnapshot.rootNodeID == root.id) #expect(treeSnapshot.node(for: root.id)?.nodeName == "#document") @@ -226,190 +236,203 @@ actor ContractDataKitActor { _ = treeController.updates _ = treeController.revealRequests - context.select(root) - #expect(context.selectedNode === root) - context.select(nil) - context.selectContext(nil) - context.clearNetworkRequests() - #expect(context.selectedNode == nil) - #expect(context.selectedContext == nil) + try context.selectDOMNode(root) + #expect(try context.selectedDOMNode === root) + try context.selectDOMNode(nil) + await context.clearNetworkRequests() + #expect(try context.selectedDOMNode == nil) + #expect(try context.runtimeContexts.isEmpty) } - func assertFakeBackendDrivesDOMNetworkAndRuntimeContracts() async throws { - let context = modelContext() - let document = WebInspectorProxyTestFixtures.domDocument( + func assertRawPeerDrivesDOMNetworkAndRuntimeContracts() async throws { + try await start(document: ContractTestSupport.documentResult( id: "contract-document", documentURL: "https://example.com/", childNodeCount: 1 - ) - let target = try await start(document: document) - - await runtime.backend.emit( - .setChildNodes(parent: WebInspectorProxyTestFixtures.domNodeID("contract-document"), nodes: [ - WebInspectorProxyTestFixtures.domNode( - id: "contract-element", - nodeType: 1, - nodeName: "MAIN", - localName: "main", - attributes: ["data-second": "2", "data-contract": "dom"], - attributeList: [ - DOM.Attribute(name: "data-contract", value: "dom"), - DOM.Attribute(name: "data-second", value: "2"), - ] - ), - ]), - target: target - ) + )) - try await ContractTestSupport.waitUntil { - guard let root = context.rootNode, + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "DOM.setChildNodes", + parameters: ContractTestSupport.setChildNodesParameters() + ) + try await ContractTestSupport.waitUntil(isolation: self) { + guard let root = try? context.rootDOMNode, case let .loaded(children) = root.children else { return false } return children.first?.attributes["data-contract"] == "dom" } - let root = try #require(context.rootNode) + let root = try #require(try context.rootDOMNode) guard case let .loaded(children) = root.children else { Issue.record("Expected the seeded document to load children.") return } let child = try #require(children.first) - #expect(context.node(for: child.id) === child) + #expect(try context.domNode(id: child.id) === child) #expect(child.attributeList.map(\.name) == ["data-contract", "data-second"]) - - let treeController = try await context.treeController() - #expect(treeController.snapshot.selectorPath(for: child.id) == "main") #expect(try context.selectorPath(for: child) == "main") #expect(try context.xPath(for: child) == "/main") - await runtime.backend.enqueue("
", for: "DOM", method: "getOuterHTML") - #expect(try await child.copyText(.html) == "
") - #expect(try await child.copyText(.selectorPath) == "main") - - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - try await child.highlight() - await runtime.backend.enqueue((), for: "DOM", method: "hideHighlight") - try await context.hideHighlight() - await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.setElementPickerEnabled(true) - #expect(context.isElementPickerEnabled) - await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.setElementPickerEnabled(false) - #expect(context.isElementPickerEnabled == false) - await runtime.backend.enqueue((), for: "DOM", method: "removeNode") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await child.delete() - await runtime.backend.enqueue((), for: "Page", method: "reload") - try await context.reloadPage() - - let domCommands = await runtime.backend.recordedCommands() - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "getOuterHTML"))) - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "highlightNode"))) - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "hideHighlight"))) - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "setInspectModeEnabled"))) - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "removeNode"))) - #expect(domCommands.contains(RecordedCommand(domain: "DOM", method: "markUndoableState"))) - #expect(domCommands.contains(RecordedCommand(domain: "Page", method: "reload"))) - - let request = WebInspectorProxyTestFixtures.networkRequest( - id: "contract-request", - url: "https://example.com/data.json", - headers: ["Accept": "application/json"] + let copyHTMLTask = Task { + try await context.copyText(.html, for: child) + } + var command = try await replyNext( + expectedMethod: "DOM.getOuterHTML", + result: ContractTestSupport.outerHTMLResult( + "
" + ) ) - let requestController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - await ContractTestSupport.emitFinishedRequest(request, target: target, backend: runtime.backend) + #expect(try await copyHTMLTask.value == "
") - let requests: WebInspectorFetchedResults = context.fetchedResults() - try await ContractTestSupport.waitUntil { - requests.items.first?.state == .finished + let highlightTask = Task { + try await context.highlightDOMNode(child) } - let requestModel = try #require(requests.items.first) - #expect(requestModel.url == "https://example.com/data.json") - #expect(requestModel.method == "GET") - #expect(requestModel.status == 200) - #expect(requestModel.statusText == "OK") - #expect(requestModel.responseURL == "https://example.com/data.json") - #expect(requestModel.responseSource == "network") - #expect(requestModel.hasResponse) - #expect(requestModel.hasResponseBody) - #expect(requestModel.responseHeaders["Content-Type"] == "application/json") - #expect(requestModel.decodedDataLength == 7) - #expect(requestModel.encodedDataLength == 4) - #expect(requestModel.sourceMapURL == "data.json.map") - #expect(requestModel.metrics?.networkProtocol == "h2") - #expect(requestModel.metrics?.remoteAddress == "203.0.113.30:443") - #expect(requestModel.metrics?.encodedDataLength == 4) - #expect(requestModel.metrics?.decodedBodyLength == 7) - #expect(context.registeredRequest(for: requestModel.id) === requestModel) - #expect(requestController.snapshot.itemIDs == [requestModel.id]) - - await runtime.backend.enqueue( - Network.Body(data: "{\"ok\":true}", base64Encoded: false), - for: "Network", - method: "getResponseBody" - ) - await requestModel.fetchResponseBody() - #expect(requestModel.responseBody.phase == .loaded) - #expect(requestModel.responseBody.text == "{\"ok\":true}") - #expect(requestModel.responseBody.isBase64Encoded == false) - - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: WebInspectorProxyTestFixtures.runtimeRemoteObject( - id: "contract-evaluation", - kind: .string, - description: "contract", - value: .string("contract") - ) - ), - for: "Runtime", - method: "evaluate" - ) + command = try await replyNext(expectedMethod: "DOM.highlightNode") + try await highlightTask.value - let evaluation = try await context.evaluate("document.title") - #expect(evaluation.isException == false) - #expect(evaluation.object.kind == .string) - #expect(evaluation.object.value == .string("contract")) - #expect(evaluation.object.description == "contract") - #expect(evaluation.object.canRequestProperties) - } + let hideTask = Task { + try await context.hideDOMHighlight() + } + command = try await replyNext(expectedMethod: "DOM.hideHighlight") + try await hideTask.value - func waitForConsoleMessage(text: String) async throws { - let context = modelContext() - let messages: WebInspectorFetchedResults = context.fetchedResults() - try await ContractTestSupport.waitUntil { - messages.items.contains { $0.text == text } + let enablePicker = Task { + try await context.setElementPickerEnabled(true) } - } + _ = try await replyNext(expectedMethod: "Inspector.enable") + _ = try await replyNext(expectedMethod: "Inspector.initialized") + _ = try await replyNext(expectedMethod: "DOM.setInspectModeEnabled") + try await enablePicker.value + #expect(try context.isElementPickerEnabled) + + let disablePicker = Task { + try await context.setElementPickerEnabled(false) + } + _ = try await replyNext(expectedMethod: "DOM.setInspectModeEnabled") + _ = try await replyNext(expectedMethod: "Inspector.disable") + try await disablePicker.value + #expect(try context.isElementPickerEnabled == false) - private func modelContext() -> WebInspectorContext { - if let context { - return context + let deleteTask = Task { + try await context.removeDOMNodes([child]).appliedNodeIDs } - let context = WebInspectorContext(inspectorContainer, isolation: self) - self.context = context - return context - } + _ = try await replyNext(expectedMethod: "DOM.removeNode") + _ = try await replyNext(expectedMethod: "DOM.markUndoableState") + let deletedNodeIDs = try await deleteTask.value + #expect(deletedNodeIDs == [child.id]) - private func requirePersistentModel(_ model: Model) { - #expect(Set([model]).contains(model)) + let reloadTask = Task { + try await context.reload() + } + _ = try await replyNext(expectedMethod: "Page.reload") + try await reloadTask.value + + let requestResults = try await context.networkRequests() + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.requestWillBeSent", + parameters: ContractTestSupport.requestWillBeSentParameters() + ) + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.responseReceived", + parameters: ContractTestSupport.responseReceivedParameters() + ) + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.loadingFinished", + parameters: ContractTestSupport.loadingFinishedParameters() + ) + try await ContractTestSupport.waitUntil(isolation: self) { + requestResults.items.first?.state == .finished + } + let request = try #require(requestResults.items.first) + #expect(request.url == "https://example.com/data.json") + #expect(request.status == 200) + #expect(request.responseHeaders["Content-Type"] == "application/json") + #expect(request.metrics?.networkProtocol == "h2") + + let bodyTask = Task { + let body = try await context.responseBody(for: request, isolation: self) + return BodySnapshot( + phase: body.phase, + text: body.text, + isBase64Encoded: body.isBase64Encoded + ) + } + _ = try await replyNext( + expectedMethod: "Network.getResponseBody", + result: ContractTestSupport.responseBodyResult( + "{\"ok\":true}", + base64Encoded: false + ) + ) + let body = try await bodyTask.value + #expect(body.phase == .loaded) + #expect(body.text == "{\"ok\":true}") + #expect(body.isBase64Encoded == false) + + let evaluationTask = Task { + try await context.withRuntimeObjectGroup(named: "contract") { group in + let evaluation = try await group.evaluate("document.title") + return EvaluationSnapshot( + isException: evaluation.isException, + kind: evaluation.object.kind, + value: evaluation.object.value, + description: evaluation.object.description, + canRequestProperties: evaluation.object.canRequestProperties + ) + } + } + _ = try await replyNext( + expectedMethod: "Runtime.evaluate", + result: ContractTestSupport.evaluationResult() + ) + _ = try await replyNext(expectedMethod: "Runtime.releaseObjectGroup") + let evaluation = try await evaluationTask.value + #expect(evaluation.isException == false) + #expect(evaluation.kind == .string) + #expect(evaluation.value == .string("contract")) + #expect(evaluation.description == "contract") + #expect(evaluation.canRequestProperties) + + _ = command } - func stopContext(enqueueShutdownReplies: Bool = true) async { - guard let context else { - return + func close() async throws { + let closeTask = Task { + await context.close() } - if enqueueShutdownReplies { - await ContractTestSupport.enqueueDataKitShutdownReplies(on: runtime.backend) + for expectedMethod in [ + "Runtime.disable", + "Console.disable", + "Network.disable", + "CSS.disable", + "Page.disable", + ] { + _ = try await replyNext(expectedMethod: expectedMethod) } - await context.stop() - #expect(context.state == .detached) - #expect(context.teardownError == nil) + await closeTask.value + #expect(context.state == .closed) } - func close() async { - await stopContext() - await inspectorContainer.close() + @discardableResult + private func replyNext( + expectedMethod: String, + result: WebInspectorTestJSONObject = .empty + ) async throws -> WebInspectorTestPeer.Command { + let command = try await runtime.peer.commands.next() + commands.append(command) + try #require(command.destination == .target("page-main")) + try #require(command.method == expectedMethod) + try await runtime.peer.reply(to: command, with: result) + return command + } + + private func requirePersistentModel( + _ model: Model + ) { + #expect(Set([model]).contains(model)) } } diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift index 9edf707a..2f042831 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryA2ProxyKitContractTests.swift @@ -1,58 +1,118 @@ +import Foundation import Testing import WebInspectorProxyKit import WebInspectorProxyKitTesting @Test -func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() +func testJSONObjectPublicInitializersValidateAndCanonicalizeTypedFixtures() throws { + struct Fixture: Codable, Equatable { + let name: String + let count: Int + } - guard case .page = target.kind else { - Issue.record("Expected WebInspectorProxyTestRuntime to install a page target.") - return + let fixture = Fixture(name: "contract", count: 2) + let encoded = try WebInspectorTestJSONObject(encoding: fixture) + let fromData = try WebInspectorTestJSONObject( + data: Data(#"{"count":2,"name":"contract"}"#.utf8) + ) + + #expect(encoded == fromData) + #expect(try encoded.decode(Fixture.self) == fixture) + #expect(throws: WebInspectorTestPeerError.invalidJSONObject) { + try WebInspectorTestJSONObject(data: Data("[]".utf8)) } - #expect(await runtime.proxy.canReload) + #expect(throws: WebInspectorTestPeerError.invalidJSONObject) { + try WebInspectorTestJSONObject(encoding: [1, 2]) + } +} - await runtime.backend.enqueue((), for: "Network", method: "enable") - try await target.network.enable() +@Test +func webInspectorProxyPublicLifecycleAndCommandSurfaceWorksFromConsumerPackage() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let page = runtime.page + let currentPageDOM: DOM = page.dom + let currentPageCSS: CSS = page.css + let currentPageNetwork: Network = page.network + let currentPageConsole: Console = page.console + let currentPageRuntime: Runtime = page.runtime + let currentPageCommands: Page = page.page + _ = ( + currentPageDOM, + currentPageCSS, + currentPageNetwork, + currentPageConsole, + currentPageRuntime, + currentPageCommands + ) - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Network", method: "enable"))) - #expect(commands.first?.targetID == target.id) + let reloadTask = Task { + try await currentPageCommands.reload() + } + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "Page.reload") + try await runtime.peer.reply(to: command) + try await reloadTask.value await runtime.proxy.close() try await runtime.proxy.waitUntilClosed() - #expect(await runtime.proxy.canReload == false) } @Test func webInspectorProxyNetworkEventsMulticastToConsumerSubscribers() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() + let page = runtime.page - let firstEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - let secondEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let eventTask = Task { + try await page.network.withEvents { firstEvents in + try await page.network.withEvents { secondEvents in + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.responseReceived", + parameters: ContractTestSupport.jsonObject([ + "requestId": "contract-multicast-request", + "response": [ + "status": 204, + "mimeType": "application/json", + ], + "type": "Fetch", + "timestamp": 42, + ]) + ) + + var firstIterator = firstEvents.makeAsyncIterator() + var firstEvent: Network.Event? + while firstEvent == nil, let pageEvent = try await firstIterator.next() { + if case let .event(_, event) = pageEvent { + firstEvent = event + } + } + + var secondIterator = secondEvents.makeAsyncIterator() + var secondEvent: Network.Event? + while secondEvent == nil, let pageEvent = try await secondIterator.next() { + if case let .event(_, event) = pageEvent { + secondEvent = event + } + } + return (firstEvent, secondEvent) + } + } } - try await runtime.backend.waitForSubscribers(domain: "Network", target: target, count: 2) + var command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "Network.enable") + try await runtime.peer.reply(to: command) - await runtime.backend.emit( - .responseReceived( - id: WebInspectorProxyTestFixtures.networkRequestID("contract-multicast-request"), - response: Network.Response(status: 204, mimeType: "application/json"), - resourceType: .fetch, - timestamp: 42 - ), - target: target - ) + command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "Network.disable") + try await runtime.peer.reply(to: command) - let firstEvent = try #require(try await ContractTestSupport.value(of: firstEventTask)) - let secondEvent = try #require(try await ContractTestSupport.value(of: secondEventTask)) + let (firstValue, secondValue) = try await eventTask.value + let firstEvent = try #require(firstValue) + let secondEvent = try #require(secondValue) guard case let .responseReceived(firstID, firstResponse, firstType, firstTimestamp) = firstEvent else { Issue.record("Expected the first subscriber to receive Network.responseReceived.") @@ -72,4 +132,35 @@ func webInspectorProxyNetworkEventsMulticastToConsumerSubscribers() async throws #expect(secondType == .fetch) #expect(firstTimestamp == 42) #expect(secondTimestamp == 42) + await runtime.close() +} + +private func domStructuredEventSurfaceCompiles(_ handle: DOM) async throws { + try await handle.withEvents { events in + _ = events + } +} + +private func cssStructuredEventSurfaceCompiles(_ handle: CSS) async throws { + try await handle.withEvents { events in + _ = events + } +} + +private func networkStructuredEventSurfaceCompiles(_ handle: Network) async throws { + try await handle.withEvents { events in + _ = events + } +} + +private func consoleStructuredEventSurfaceCompiles(_ handle: Console) async throws { + try await handle.withEvents { events in + _ = events + } +} + +private func runtimeStructuredEventSurfaceCompiles(_ handle: Runtime) async throws { + try await handle.withEvents { events in + _ = events + } } diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitCompileContract.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitCompileContract.swift index 1fb35fdd..25c6994e 100644 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitCompileContract.swift +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitCompileContract.swift @@ -8,5 +8,5 @@ func webInspectorDataKitPublicSurfaceIsUsableFromConsumerPackage() async throws let owner = ContractDataKitActor(runtime: runtime) try await owner.start() try await owner.assertPublicSurfaceIsUsable() - await owner.close() + try await owner.close() } diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitFakeBackendTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitFakeBackendTests.swift deleted file mode 100644 index 89ba3adb..00000000 --- a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitFakeBackendTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Testing -import WebInspectorDataKit -import WebInspectorProxyKit -import WebInspectorProxyKitTesting - -@Test -func fakeBackendDrivesDataKitDOMNetworkAndRuntimeContracts() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let owner = ContractDataKitActor(runtime: runtime) - try await owner.assertFakeBackendDrivesDOMNetworkAndRuntimeContracts() - await owner.close() -} - -@Test -func sharedContainerKeepsWireDomainsEnabledUntilLastContextStops() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let container = WebInspectorContainer(proxy: runtime.proxy) - let firstOwner = ContractDataKitActor(runtime: runtime, inspectorContainer: container) - let secondOwner = ContractDataKitActor(runtime: runtime, inspectorContainer: container) - - let target = try await firstOwner.start() - try await secondOwner.start(expectedSubscriberCount: 2) - - var commands = await runtime.backend.recordedCommands() - #expect(commands.filter { $0.domain == "Runtime" && $0.method == "enable" }.count == 1) - #expect(commands.filter { $0.domain == "Network" && $0.method == "enable" }.count == 1) - #expect(commands.filter { $0.domain == "Console" && $0.method == "enable" }.count == 1) - - await firstOwner.stopContext(enqueueShutdownReplies: false) - - commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Runtime", method: "disable")) == false) - #expect(commands.contains(RecordedCommand(domain: "Network", method: "disable")) == false) - #expect(commands.contains(RecordedCommand(domain: "Console", method: "disable")) == false) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "log"), - text: "second-context-still-live" - )), - target: target - ) - try await secondOwner.waitForConsoleMessage(text: "second-context-still-live") - - await secondOwner.close() - - commands = await runtime.backend.recordedCommands() - #expect(commands.filter { $0.domain == "Runtime" && $0.method == "disable" }.count == 1) - #expect(commands.filter { $0.domain == "Network" && $0.method == "disable" }.count == 1) - #expect(commands.filter { $0.domain == "Console" && $0.method == "disable" }.count == 1) -} diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift new file mode 100644 index 00000000..09b6d726 --- /dev/null +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitRawPeerTests.swift @@ -0,0 +1,12 @@ +import Testing +import WebInspectorDataKit +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +@Test +func rawPeerDrivesDataKitDOMNetworkAndRuntimeContracts() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let owner = ContractDataKitActor(runtime: runtime) + try await owner.assertRawPeerDrivesDOMNetworkAndRuntimeContracts() + try await owner.close() +} diff --git a/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift new file mode 100644 index 00000000..42bb68c3 --- /dev/null +++ b/ContractTests/Tests/WebInspectorConsumerContractTests/StoryADataKitTestingContractTests.swift @@ -0,0 +1,78 @@ +import Testing +import WebInspectorDataKit +import WebInspectorDataKitTesting + +@MainActor +@Test +func readyDataKitScenarioIsUsableFromAConsumerPackage() async throws { + let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.dom, .network]), + document: .init(children: [ + .element(id: "contract-button", name: "button") + ]), + networkReplay: [ + .init( + id: "contract-request", + url: "https://example.test/contract", + body: .init(data: "contract body") + ) + ] + ), + isolation: MainActor.shared + ) + + let requests = try await runtime.model.networkRequests() + #expect(requests.items.map(\.url) == ["https://example.test/contract"]) + let body = try await runtime.model.responseBody(for: requests.items[0]) + #expect(body.text == "contract body") + let selected = try await runtime.selectElementWithPicker( + nodeID: "contract-button" + ) + #expect(selected.localName == "button") + + try await runtime.replacePage(with: .init()) + #expect(try runtime.model.rootDOMNode?.nodeName == "#document") + let selectedDocument = try await runtime.selectElementWithPicker( + nodeID: "document" + ) + #expect(selectedDocument.nodeName == "#document") + do { + _ = try await runtime.selectElementWithPicker(nodeID: "missing") + Issue.record("Expected a missing picker fixture failure.") + } catch let error as WebInspectorDataKitTestRuntime.RuntimeError { + #expect(error == .selectedNodeMissing("missing")) + } catch { + Issue.record("Expected a DataKit testing runtime failure, got \(error).") + } + + await runtime.close() + #expect(runtime.model.state == .closed) +} + +@MainActor +@Test +func dataKitScenarioCanInjectAnAttachmentFailure() async { + do { + _ = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.network]), + attachFailure: .init( + domain: .network, + message: "contract attachment failure" + ) + ), + isolation: MainActor.shared + ) + Issue.record("Expected the scenario attachment to fail.") + } catch let failure as WebInspectorModelContext.Failure { + guard case let .bootstrap(domain, message) = failure else { + Issue.record("Expected a bootstrap failure, got \(failure).") + return + } + #expect(domain == .network) + #expect(message.contains("contract attachment failure")) + } catch { + Issue.record("Expected a DataKit model failure, got \(error).") + } +} diff --git a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift index 7c402e5b..6ca2d595 100644 --- a/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift +++ b/ContractTests/Tests/WebInspectorDataKitImportOnlyContractTests/StoryADataKitImportOnlyContract.swift @@ -7,28 +7,33 @@ func webInspectorDataKitBaseSurfaceDoesNotRequireProxyKitImport() { } private actor DataKitImportOnlyActor { - func consume(_ context: WebInspectorContext) async throws { - let requests: WebInspectorFetchedResults = context.fetchedResults() - let messages: WebInspectorFetchedResults = context.fetchedResults() - let requestsByMethod: WebInspectorFetchedResults = - context.fetchedResults(sectionBy: \.method) - let messagesByLevel: WebInspectorFetchedResults = - context.fetchedResults(sectionBy: \.level) - let requestController: WebInspectorFetchedResultsController = - context.fetchedResultsController() - let messageController: WebInspectorFetchedResultsController = - context.fetchedResultsController() + func consume(_ context: WebInspectorModelContext) async throws { + let requests = try await context.networkRequests(matching: NetworkQuery( + search: " import-only ", + methods: ["GET"], + sort: .requestTimeDescending, + section: .method, + limit: 10 + )) + let messages = try await context.consoleMessages(matching: ConsoleQuery( + sort: .insertionDescending, + section: .level, + limit: 10 + )) _ = context.state - _ = context.rootNode?.children - _ = context.selectedNode?.attributes - _ = context.selectedNode?.attributeList.first?.name - _ = context.selectedNode?.elementStyles?.sections.first?.rule?.selectorText - _ = context.selectedNode?.elementStyles?.sections.first?.style.properties.first?.name - _ = context.selectedNode?.elementStyles?.computedProperties.first?.value - _ = context.isElementPickerEnabled - context.clearNetworkRequests() - let treeController = try await context.treeController() + _ = context.pageGeneration + _ = try context.rootDOMNode?.children + _ = try context.selectedDOMNode?.attributes + _ = try context.selectedDOMNode?.attributeList.first?.name + _ = try context.selectedDOMNode?.elementStyles?.sections.first?.rule?.selectorText + _ = try context.selectedDOMNode?.elementStyles?.sections.first?.style.properties.first?.name + _ = try context.selectedDOMNode?.elementStyles?.computedProperties.first?.value + _ = try context.isElementPickerEnabled + _ = try context.runtimeContexts.first?.name + + await context.clearNetworkRequests() + let treeController = try context.domTree let treeSnapshot: DOMTreeSnapshot = treeController.snapshot _ = treeSnapshot.rootNodeID _ = treeSnapshot.nodesByID.values.first?.attributeList.first?.value @@ -38,39 +43,58 @@ private actor DataKitImportOnlyActor { _ = treeController.selectedNodeID _ = treeController.updates _ = treeController.revealRequests - if let selectedNode = context.selectedNode { + + if let selectedNode = try context.selectedDOMNode { _ = try context.selectorPath(for: selectedNode) _ = try context.xPath(for: selectedNode) - _ = try await selectedNode.copyText(.selectorPath) - try await selectedNode.highlight() - try await selectedNode.delete() + _ = try await context.copyText(.selectorPath, for: selectedNode) + try await context.highlightDOMNode(selectedNode) + _ = try await context.removeDOMNodes([selectedNode]) } - try await context.hideHighlight() + try await context.hideDOMHighlight() try await context.setElementPickerEnabled(false) - try await context.reloadPage() + try await context.reload() + _ = requests.items.first?.url _ = requests.items.first?.state _ = requests.items.first?.hasResponse _ = requests.items.first?.hasResponseBody _ = requests.items.first?.metrics - _ = requestsByMethod.sections.first?.title + _ = requests.sections.first?.title let requestSnapshot: WebInspectorFetchedResultsSnapshot = - requestController.snapshot - let requestTransaction = WebInspectorFetchedResultsTransaction( + requests.snapshot + let requestTransaction = WebInspectorFetchedResultsTransaction( oldSnapshot: requestSnapshot, newSnapshot: requestSnapshot, itemChanges: [] ) - _ = requestController.transactions + _ = requests.revision + _ = requests.updates() + try await requests.update(NetworkQuery()) _ = requestTransaction.hasChanges + if let request = requests.items.first { + _ = try await context.responseBody(for: request) + } + _ = messages.items.first?.text _ = messages.items.first?.parameters.first?.description - _ = messagesByLevel.sections.first?.id - _ = messageController.snapshot - _ = messageController.transactions - _ = try await context.evaluate("1 + 1").object.description + _ = messages.sections.first?.id + _ = messages.snapshot + _ = messages.revision + _ = messages.updates() + try await messages.update(ConsoleQuery()) + + try await context.withRuntimeObjectGroup(named: "import-only") { group in + let evaluation = try await group.evaluate("1 + 1") + _ = evaluation.object.description + _ = try await group.properties(of: evaluation.object) + _ = try await group.preview(of: evaluation.object) + } - let request = NetworkRequestSnapshot(url: "https://example.com", method: "GET") + let request = NetworkRequestSnapshot( + url: "https://example.com", + method: "GET" + ) let response = NetworkResponseSnapshot(status: 200, mimeType: "text/html") let redirect = RedirectHop(request: request, response: response, timestamp: 1) _ = redirect.request.url diff --git a/Docs/MIGRATION.md b/Docs/MIGRATION.md index 201c56c7..d11f7fdd 100644 --- a/Docs/MIGRATION.md +++ b/Docs/MIGRATION.md @@ -3,6 +3,167 @@ This standalone guide records source changes that are likely to affect app code when upgrading WebInspectorKit. Sections are grouped by release, newest first. +## Unreleased + +Unreleased builds require Swift 6.3+ and a minimum deployment target of iOS +18.4+ or macOS 15.4+. The built-in UIKit inspector remains iOS-only. + +### Make custom tab factories asynchronous + +`WebInspectorTab` factories are now `async throws` and declare the model +domains they require. The root inspector joins concurrent requests for the same +tab, presents native loading and failure states, supports retry, and cancels and +awaits unfinished factories during root teardown: + +```swift +let consoleTab = WebInspectorTab( + id: "console", + title: "Console", + requiredDomains: [.console] +) { session in + let messages = try await session.model.consoleMessages() + return ConsoleViewController(messages: messages) +} +``` + +The returned controller remains root-owned and is reused across compact and +regular hosts. Attachment and page-generation changes do not recreate it. + +### Use the logical page and scoped domain events + +`WebInspectorProxy.page` is now the only public page handle. Physical +`WebInspectorTarget` values, `currentPage`, `waitForCurrentPage()`, `canReload`, +and the duplicate proxy-level `reload()` were removed. Commands resolve the +current physical WebKit target when they are sent, so a stored page handle +continues across navigation and process replacement. + +Separate domain `enable()` / `disable()` calls and cold `events` streams were +also removed. Use `withEvents` to register the subscriber before WebKit domain +activation and to await balanced cleanup: + +```swift +let proxy = try await WebInspectorProxy(attachingTo: webView) + +try await proxy.page.network.withEvents { events in + for try await event in events { + switch event { + case .reset: + resetNetworkPresentation() + case let .event(_, event): + handleNetworkEvent(event) + } + } +} + +try await proxy.page.page.reload(ignoringCache: true) +``` + +### Replace the semantic test backend with the raw peer + +`WebInspectorProxyKitTesting` now drives ProxyKit's production connection core +through a raw WebKit peer. `WebInspectorProxyTestRuntime.backend`, +`WebInspectorTestBackend`, and manually constructed `(proxy, backend)` runtimes +were removed. Start the owned runtime, use `runtime.peer`, and explicitly await +`runtime.close()` at the end of every test. + +Before: + +```swift +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +let runtime = try await WebInspectorProxyTestRuntime.start() +let page = try await runtime.proxy.waitForCurrentPage() + +await runtime.backend.enqueue((), for: "Page", method: "reload") +try await page.page.reload() + +let commands = await runtime.backend.recordedCommands() +precondition(commands.contains(RecordedCommand(domain: "Page", method: "reload"))) +``` + +After: + +```swift +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +let runtime = try await WebInspectorProxyTestRuntime.start() +let reload = Task { + try await runtime.page.page.reload() +} + +let command = try await runtime.peer.commands.next() +precondition(command.destination == .target("page-main")) +precondition(command.method == "Page.reload") +precondition(command.parameters == try WebInspectorTestJSONObject( + json: #"{"ignoreCache":false}"# +)) + +try await runtime.peer.reply(to: command) +try await reload.value +await runtime.close() +``` + +Apply these mappings to other tests: + +- Replace `enqueue(result, for:domain:method:)` by starting the operation in a + task, consuming its raw command from `runtime.peer.commands.next()`, asserting + `destination`, the full wire `method`, and `parameters`, then calling + `peer.reply(to:with:)`. +- Replace injected Swift errors with the boundary being tested: use + `peer.fail(_:message:)` for a protocol error reply, + `peer.failConnection(with:)` for a fatal transport failure, and + `peer.closeConnection()` for clean remote EOF. Arbitrary Swift `Error` + injection is no longer supported. +- Replace `RecordedCommand` and backend histories with a consumer-owned + `[WebInspectorTestPeer.Command]`. Inspect command fields instead of comparing + whole commands because each command carries an opaque correlation identity. + An awaited `reply` or `fail` is the command-completion boundary. +- Give the command FIFO one drain owner. Multiple concurrent `next()` consumers + race to consume commands and cannot reliably wait for a particular method. + The owner should record or route commands for other test tasks. +- Replace `hold` and the product `WebInspectorTestGate` by retaining the command + and delaying its reply with synchronization owned by the test. The testing + product no longer ships a gate abstraction. +- Replace typed semantic event emission with `emitTargetEvent` or + `emitRootEvent`, passing raw parameters as + `WebInspectorTestJSONObject(json:)`, `WebInspectorTestJSONObject(data:)`, or + `WebInspectorTestJSONObject(encoding:)`. Use `createTarget`, + `commitProvisionalTarget`, and `destroyTarget` for target lifecycle input. +- `waitForSubscribers` and APIs that inject generations, replay markers, or + synthetic snapshots have no raw-peer equivalent. Drain until the real + `.enable` command arrives, complete it through the peer, and let the + production core derive event sequence, generation, replay, and snapshot + boundaries. + +### Use ready DataKit test scenarios for model-level tests + +Tests whose subject is a DataKit model no longer need to consume and reply to +unrelated startup commands. Add the `WebInspectorDataKitTesting` product and +start an actor-confined, ready context: + +```swift +let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.dom, .network]), + document: .init(children: [ + .element(id: "result", name: "article") + ]), + networkReplay: [ + .init(id: "request-1", url: "https://example.test/result") + ] + ) +) + +let model = runtime.model // already attached and replay is applied +try await runtime.replacePage(with: .init()) +await runtime.close() +``` + +Use `WebInspectorProxyKitTesting` directly when the wire command, raw JSON, +target registry, or exact reply ordering is the subject of the test. + ## v0.2.0 These notes apply when upgrading from `v0.1.5` or earlier to `v0.2.0`. diff --git a/Docs/WebInspectorKitsArchitecture.md b/Docs/WebInspectorKitsArchitecture.md new file mode 100644 index 00000000..007bc955 --- /dev/null +++ b/Docs/WebInspectorKitsArchitecture.md @@ -0,0 +1,2359 @@ +# WebInspectorProxyKit and WebInspectorDataKit Architecture + +- Status: implemented and validated +- Scope: breaking redesign of WebInspectorProxyKit, WebInspectorDataKit, their + testing products, and the built-in UIKit consumers +- Minimum deployment: iOS 18.4 and macOS 15.4 +- Baseline revision: `88865971b78724a4444373e5a83606b3377ad86d` + +## Decision + +The current APIs are not safe or coherent enough to preserve for source +compatibility. The migration will replace them instead of wrapping them. + +The redesign has four governing invariants: + +1. A protocol event subscription that causes WebKit domain enablement is + registered before enable is sent, so that enable-time replay cannot race its + first subscriber. Later subscribers explicitly start from the current page + generation and future events; ProxyKit does not invent replay WebKit did not + send. +2. One connection core owns physical target membership, reply routing, domain + leases, event buffering, and terminal state. No public handle caches a second + copy of that state. +3. DataKit exposes one non-`Sendable`, caller-confined model context over stable + identity models and snapshot-plus-diff query results. The context and every + identity it vends inherit the actor that stores and uses them; UIKit owns its + context on MainActor, while a headless consumer may own an independent + context on any actor. A successful `attach(to:)` means the configured state + is ready to use. +4. Resources with asynchronous teardown have an explicit scoped or `async` + close operation. `isolated deinit` is a synchronous backstop, never a + substitute for deterministic teardown. + +These invariants intentionally break the existing `enable()` / `events`, +`WebInspectorContainer` / dynamically checked `WebInspectorContext`, generic +fetch, and duplicate domain-controller APIs. + +## Scope Contract + +### Outcomes + +- A direct ProxyKit consumer can attach, issue typed commands, and consume an + atomic bounded event stream without understanding target IDs, enable ordering, + or reference counts. +- A custom UI can attach one DataKit context, observe readiness and failure, + select only the domains it needs, and use the same DOM, Network, Console, + Runtime, and CSS state as the built-in UI. +- Navigation and WebKit process replacement retain one logical page handle while + commands and active capabilities move to the new physical target. Consumers + receive an ordered reset boundary before any event from the new binding. +- Explicit detach/close waits for stream termination, native detach, and + inspectability restoration. Retain cycles cannot make cleanup unreachable. +- ProxyKitTesting and DataKitTesting can describe the behaviors above without + hand-scripting unrelated startup replies or polling readiness. + +### Primary consumers + +1. The built-in UIKit inspector and Monocly integration. +2. Public-only contract consumers: + - a custom Console tab receiving a DataKit context; + - a direct ProxyKit Network event consumer. + +The second consumers are contract tests because the repository currently has no +second production consumer of either core product. + +### Preserved behavior + +- Typed protocol commands and payload DTOs. +- Opaque scoped protocol identifiers and frame-aware routing. +- Identity-preserving DataKit model objects. +- DOM snapshots and incremental tree updates. +- Observable Network collection topology and lazy response-body loading. +- Undoable DOM editing and explicit partial-mutation reporting. +- The UIKit DOM and Network feature set and Monocly's reusable shared-session + lifecycle. + +### Non-goals + +- Expanding the supported Web Inspector protocol surface or exposing a raw + command escape hatch from production ProxyKit. ProxyKitTesting's inverse + raw-wire peer is a test transport, not a production command API. +- Building a new AppKit inspector UI. ProxyKit and DataKit continue to support + macOS; the app-facing UI remains UIKit-only. +- Redesigning the visual presentation of the built-in inspector. +- Replacing the native symbol-resolution strategy except where ownership and + teardown must change. +- Preserving source compatibility with the APIs explicitly deleted below. + +## Measured Baseline + +The baseline is source-derived and intentionally records lexical counts so the +same commands can be rerun after migration. + +| Metric | ProxyKit | DataKit | ProxyKitTesting | UIKit implementation | +| --- | ---: | ---: | ---: | ---: | +| Swift LOC | 10,041 | 12,192 | 757 | 18,508 | +| lexical `public` | 431 | 507 | 45 | 32 | +| lexical `package` | 478 | 145 | 13 | 463 | +| top-level public types | 11 | 53 | 6 | 3 | + +Additional coupling signals: + +- `WebInspectorContext.swift` is 4,327 lines with 63 source-level stored + properties (60 outside `DEBUG`). +- DataKit contains 202 `isolated (any Actor)` annotations and 57 + `requireOwner` sites. +- `WebInspectorProxy.pageTarget` has 23 references even though + `TransportTargetRegistry` already owns the current physical target. +- Current-page versus physical-target routing is decided at nine sites in three + files. +- Domain activation decisions occur at 13 sites in six files and are split + between ProxyKit and DataKit. +- There are 58 UIKit platform-gated files. This is a known package/platform + boundary caused by the UIKit-only product and is excluded from the core + rearchitecture metric; AppKit UI is a separate project. +- `ContractTests` uses ordinary imports and no `@testable`, so it remains the + external API gate. + +Baseline validation on the branch passed the shared iOS simulator scheme: 467 +tests, zero failures, on `iPhone 17` / iOS Simulator 27.0. The result bundle is +recorded outside the repository by Xcode; it is evidence, not a committed +artifact. The public-only ContractTests package also passed its six tests on +macOS. + +External source evidence was read at fixed local revisions: + +- Swift `9a6fb89946fa748420c87627ac0f892543e53b51`: non-MainActor isolated + deinitialization availability in `include/swift/AST/RuntimeVersions.def` and + `test/Concurrency/deinit_isolation_availability.swift`; rejection of isolated + deinitializers on ordinary classes in `deinit_isolation.swift`; region-based + transfer diagnostics and caller-executor behavior in the concurrency tests. +- Xcode's SwiftData interface: `ModelContext` is deliberately unavailable for + `Sendable` conformance, while `ModelActor` supplies a separate actor/executor + owner. This is the analog for caller confinement, not evidence that an + ordinary class can acquire a runtime-selected nominal actor isolation. +- WebKit `9d2c43b4dc9d9c47448c510c87e79ecaf40b60a4`: enable-time replay in Runtime + protocol and Console/Network/CSS/Inspector agents, plus provisional target + commit ordering in `WebPageInspectorController.cpp`; page-only Inspector and + `DOM.requestNode` contracts in `Inspector.json` / `DOM.json`, the unsupported + frame stub in `FrameDOMAgentStubs.cpp`, and main-target picker resolution in + `InspectorObserver.js` / `DOMManager.js`. In both + `Source/WebCore/inspector/agents/InspectorDOMAgent.cpp` and + `Source/WebCore/inspector/agents/frame/FrameDOMAgent.cpp`, `getDocument` + resets the agent's current node bindings before rebuilding the document root + at depth two. `setDocument` also resets those bindings and emits + `documentUpdated` once a previously requested document is ready. This is the + source evidence for treating every `documentUpdated` as a node-identity epoch + change and retrying an in-flight bootstrap rather than merging its stale + reply. In `Source/JavaScriptCore/inspector/agents/InspectorConsoleAgent.cpp`, + `InspectorConsoleAgent::clearMessages` releases WebKit's internal `"console"` + object group before dispatching `messagesCleared`. This is the ownership + evidence for invalidating Console-originated remote objects locally without a + second `Runtime.releaseObjectGroup` command from DataKit. +- Swift Evolution [SE-0371: Isolated synchronous deinit](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md) + for the language-level lifecycle contract. + +Local Swift 6.3.3 probes were compiled through SIL with strict concurrency, not +only type-checked. They prove that `WebInspectorModelContext` cannot satisfy a +`Sendable` requirement; `nonisolated(nonsending)` async methods preserve the +caller executor; a detached feed task with weak actor/context edges deallocates +its owner; and a fully checked actor bridge cannot transfer the weak +non-Sendable context back to an arbitrary runtime actor. Swift's region rules +cannot reject every deliberate escape hidden behind an actor method, so the +single delivery bridge also preconditions the actor executor bound at attach. +This is why the design permits one narrow private unchecked weak bridge and no +other unchecked model ownership. + +## Findings and Broken Invariants + +### P0: event subscription is non-atomic + +Every public domain currently exposes separate `enable()` and cold `events`. +The stream does not register its subscriber until iteration starts. WebKit may +send initial Runtime contexts, Console backlog, active Network sockets, CSS +style sheets, or pending inspect data before the enable reply. DataKit privately +works around this with `subscribe -> barrier -> reset -> enable`, but direct +ProxyKit consumers cannot do so. + +Owner correction: ProxyKit owns subscriber registration and domain enable as one +operation. The workaround and domain reference counts leave DataKit. + +The guarantee is deliberately not “every subscriber receives a historical +snapshot.” The first lease receives WebKit's enable-time replay because it was +registered before enable. A subscriber joining an already-enabled capability +receives an initial page-generation marker followed by future events only. +Stateful consumers that require their own snapshot must issue the domain's +explicit snapshot command or own a separate connection; ProxyKit does not cache +protocol history as a second source of truth. + +### P0: connection ownership is cyclic and terminal state is split + +The current graph contains both: + +```text +WebInspectorProxy -> pageTarget -> WebInspectorProxy +TransportSession -> native backend -> bridge callback -> receiver -> TransportSession +``` + +The first makes the public page cache keep its proxy alive. The second makes +native detach and inspectability restoration unreachable without an explicit +close. Close state also exists independently in the proxy, transport, and fatal +callback logger. + +Owner correction: a single connection core owns terminal state. Handles point +to the core, the core never stores handles, and the native callback captures its +receiver weakly. + +### P0: the logical page has two sources of truth + +`TransportTargetRegistry` is updated for every target message, but +`WebInspectorProxy.pageTarget` changes only while a package-only lifecycle +stream is consumed. `currentPage`, `canReload`, and routing can therefore become +stale as an accidental consequence of consumer behavior. + +Owner correction: the transport registry is the only physical membership +owner. A stable logical page resolves its current physical binding through the +core for each operation. + +### P0: DataKit readiness is not part of attachment + +`WebInspectorContainer.mainContext` returns and starts model setup in an +unstructured task. The public status type has no public producer. A caller can +receive attach success and later encounter a hidden model failure. Network-only +consumers nevertheless pay for Inspector, Runtime, DOM, Console, and Network +startup. + +Owner correction: the DataKit context owns an awaited transition and only starts +configured capabilities plus their declared dependencies. + +### P0: actor ownership is represented by a retained token and runtime checks + +`WebInspectorContext` strongly stores `any Actor`, dynamically checks it at 57 +sites, and repeats an isolation parameter across 202 declarations. When that +actor stores the Context, the graph is cyclic before any event task starts: +`owner actor -> context -> stored owner actor`. The token still does not give the +plain class nominal actor isolation or an `isolated deinit`. + +Owner correction: the replacement `WebInspectorModelContext` is non-`Sendable`, +never strongly retains its actor, and is confined by the actor that stores it in +the same manner as SwiftData's non-Sendable `ModelContext`. Synchronous model +access needs no isolation argument; async graph operations use caller-executor +semantics. Attachment records one weak actor identity solely to assert the +private feed-application boundary and to install weak event delivery. Heavy +query projection remains behind internal actors, and immutable Sendable +snapshots/deltas are the only supported way to cross between a custom model +actor and UIKit. + +### P0: fetched-results setup can permanently miss a topology change + +Creating initial `WebInspectorFetchedResults` state and registering it with the +current Context is synchronous on the owner, so the producer boundary is sound. +The public consumer boundary is not: `snapshot` and `transactions` are separate +FRC properties, the relay has no initial element, and a result drops a +transaction when it has no continuation. A mutation between snapshot read and +stream registration is therefore lost permanently. The built-in Network UI +partly avoids the race by subscribing first and later reloading a full snapshot, +but that call-site ordering is not a public contract. + +Owner correction: preserve snapshot plus transaction, but make +`WebInspectorFetchedResults` own one atomic subscription that begins with its +current snapshot. Each bounded update remains self-contained so a slow consumer +can replace from the full new snapshot after a revision gap. The zero-state FRC +wrapper is removed. + +### P1: collection topology is off-actor but not yet truly incremental + +`NetworkRequestIndex` correctly projects compact records and performs query/diff +work away from MainActor, and the UIKit consumer applies transactions instead of +reloading all model identities. However, each mutation still scans and may sort +all records, each queued transaction contains old and new full snapshots, and +the relay is unbounded. Initial fetch and descriptor replacement also build +query state on the owner actor. Console performs its filtering, sorting, and +diffing on the owner for every registered result. There is no 10,000-record or +stalled-subscriber gate. + +Owner correction: keep and improve the record-index boundary, add the same +boundary for Console, cap queued result state to the newest self-contained +snapshot/delta, and measure the large-record paths. Adding public domain model +facades would not affect this cost. + +### P1: DataKit advertises unsupported generic queries + +The public fetch protocol permits external conformance, but implementation +branches only support `NetworkRequest` and `ConsoleMessage`. Arbitrary sort and +section key paths trap outside hard-coded lists. `WebInspectorFetchRequest` is +an unused mutable mirror, `WebInspectorStaleModelPolicy` has one unused case, +and `WebInspectorFetchedResultsController` wraps results primarily to expose a +second update stream. + +Owner correction: Network and Console own closed, concrete query vocabularies +and preserve the existing `WebInspectorFetchedResults` snapshot-plus-diff +contract. Unsupported requests are unrepresentable, initial state and update +registration become atomic, and slow consumers cannot create an unbounded +transaction backlog. + +### P1: domain APIs duplicate operations and disagree on semantics + +Context methods, model convenience methods, and computed domain-controller +wrappers provide up to three paths for tree access, selection, child loading, +delete, highlight, picker, reload, evaluate, fetch, clear, and CSS mutation. +The direct tree API throws before root readiness while the domain wrapper +returns an empty live controller. CSS style lookup also mutates global DOM +selection as a side effect. + +Owner correction: package-internal domain stores become the sole writers after +the relevant `WebInspectorContext` write sets are moved into them. They are not +new public model wrappers. `WebInspectorModelContext` is the public facade, +while existing identity, tree, fetched-results, history, picker, and remote +resource types remain the public domain-specific values. Cross-domain +dependencies are internal and CSS queries never write DOM selection. + +### P1: errors and remote resources are not usable contracts + +Some DOM backend failures are swallowed behind throwing facades, stale models +alternate between trap and throw, partial deletion information is package-only, +and CSS mutations can fail in fire-and-forget tasks. Runtime model objects are +retained without a deterministic public object-group lifetime. + +Owner correction: expected runtime failures are public typed errors or explicit +partial outcomes. Programmer-contract violations alone use preconditions. +Runtime remote handles belong to explicit object groups with scoped asynchronous +release. + +### P1: custom tabs and testing claims are incomplete + +Custom tabs receive a UI session whose DataKit context is package-only, so the +documented custom Console story cannot read Console data. The existing +ProxyKitTesting backend is a second semantic implementation: it accepts decoded +typed events, synthetic targets, and preselected routes instead of exercising +the production connection core's raw JSON decoding, sequencing, target +membership, and generation boundaries. DataKit consumers then have to script +unrelated startup behavior and busy-poll private readiness. + +Owner correction: UIKit keeps its presentation-only `WebInspectorSession`, +which publicly exposes one DataKit `WebInspectorModelContext` as `model` and is +passed to custom tabs. The root controller, not the session, owns custom content +instances so a tab may retain the session without a cycle. ProxyKitTesting +exposes one raw-wire `WebInspectorTestPeer` below the production connection core +and an explicit `WebInspectorProxyTestRuntime` resource owner. A separate +DataKitTesting product composes that peer into model-level scenarios so its +consumers do not script unrelated startup replies. + +## Package and Ownership Design + +The target dependency graph remains directional: + +```text +WebInspectorNativeBridge + | + v +WebInspectorProxyKit <--- WebInspectorProxyKitTesting + | + v +WebInspectorDataKit <--- WebInspectorDataKitTesting + | + v +WebInspectorUIBase / UIDOM / UINetwork / UISyntaxBody + | + v +WebInspectorUI -> WebInspectorKit +``` + +`WebInspectorDataKitTesting` may depend on ProxyKitTesting. Production targets +never depend on a testing target. No new general-purpose Core, Shared, Service, +Manager, or Utils target is introduced. + +The UIKit `WebInspectorSession` remains a real presentation owner; it is not +moved into DataKit. It stores one MainActor-confined `WebInspectorModelContext` +and exposes it as `public let model`. The umbrella `WebInspectorKit` target +directly depends on and re-exports DataKit as well as the UIKit entry points so +a custom tab can request `session.model.consoleMessages()` with one import. +This preserves `import WebInspectorKit; WebInspectorSession()` while keeping +semantic model state in DataKit and UI-specific state in WebInspectorUI. + +### Variation axes and absorption points + +| Variation | Absorbed by | Must not leak into | +| --- | --- | --- | +| live native bridge versus raw-wire test peer | transport boundary below the same connection core | domain handles and DataKit models | +| physical target replacement | core target/capability registries and ordered generation boundary | public page handle and UIKit controllers | +| selected DataKit domains | context configuration and capability dependency table | unrelated store startup branches | +| Network/Console filtering and ordering | concrete query value and result owner | generic model protocols or arbitrary key paths | +| attached/detached/closed lifecycle | one context transition state machine | per-domain ad hoc flags | +| document versus binding lifetime | domain-specific epochs driven by the ordered feed | one coarse global stale flag | +| iOS UIKit presentation versus no AppKit UI | existing UI target/file boundary | ProxyKit and DataKit semantic code | +| timeouts and deterministic test scheduling | connection configuration plus package test-support clocks/gates | public protocol DTOs or testing product surface | + +The production connection initializer creates the native backend. The public +testing runtime installs a raw-wire peer below the same connection core; package +tests may additionally inject a clock. These are concrete boundaries, not public +backend protocols with an unsupported external-conformance promise. + +### Owner map after migration + +| State or effect | Sole owner | Allowed writers | +| --- | --- | --- | +| physical target membership and current binding | `ConnectionCore`'s target registry | inbound target lifecycle | +| logical inspected page | immutable `WebInspectorPage` handle | none; resolves through core | +| command IDs, replies, routing, terminal cause | `ConnectionCore` actor | send, inbound receive, close/fail | +| domain/capability reference counts | core capability registry | structured acquire/release and retarget | +| subscriber buffers and ordered model feed | core event broker | decoded inbound events and termination | +| native bridge and detach | `@MainActor NativeAttachment` | attach and deterministic close | +| original inspectability and same-view lease membership | per-web-view `@MainActor InspectabilityCoordinator` | lease acquire/final release | +| model attachment and physical binding generations | caller-confined `WebInspectorModelContext` | owning actor via attach, detach, close, and ordered feed application | +| DOM identity, tree, selection, edits, and node-bound CSS resources | package-internal `DOMStateStore` | DOM/CSS events and awaited DOM/CSS command results | +| Network identity registry and query membership | package-internal `NetworkRequestStore` plus its off-main-actor index | Network events and clear/load operations | +| Console identity registry, query membership, and Console-originated remote-object validity | package-internal `ConsoleMessageStore` plus its off-main-actor index | Console events and clear operations | +| one query projection's snapshot and delta sequence | public `WebInspectorFetchedResults` | its owning internal store only | +| Runtime contexts and remote groups | package-internal `RuntimeStateStore` | Runtime events and scoped evaluation | +| UI tabs and page style | UIKit `WebInspectorSession` and its package interface model | presentation and page-style events only | +| content-controller cache and retirement | root `WebInspectorViewController` | tab selection and root presentation lifecycle | +| tab layout/scroll/render caches | UIKit controllers | presentation events only | + +`ConnectionCore` is an implementation actor. The root `WebInspectorProxy` is the +sole direct close owner and strongly retains the core. `WebInspectorPage`, +`WebInspectorTarget`, and every domain endpoint are Sendable weak lifecycle +handles: retaining a child handle does not keep the root connection alive. A +command, generation query, or structured event scope resolves the root for that +operation and fails with `closed` if it has gone away; a cold stream finishes +immediately. Active model use is instead kept alive by its context/session owner. +Dropping the root is only a synchronous local-resource backstop; explicit +`close()` remains the deterministic asynchronous detach contract. + +### Native attachment ownership + +`NativeAttachment` owns one bridge, its receiver sink, and one token from an +`InspectabilityCoordinator` keyed weakly by `WKWebView`. The coordinator, not an +individual attachment, captures the original `isInspectable` value. It keeps the +view inspectable while any token exists and restores the original value only on +the final release. Closing one of two connections to the same view therefore +cannot disable the other. `NativeAttachment` does not retain a sink that in turn +strongly retains `ConnectionCore`: the receiver-to-core edge is weak and the +bridge callback weakly captures the receiver. Thus the native graph is acyclic; +explicit close rejects new messages, finishes core state, synchronously detaches +the bridge, releases the token, and only then resumes close waiters. + +### Apple framework analogs + +- The connection follows `URLSession`: it owns transport policy and requires an + explicit invalidation/close operation after which it is not reusable. +- A structured domain event scope follows task/resource scopes rather than a + notification singleton: cancellation requests teardown, and completion of the + scope confirms that teardown has balanced its capability lease. +- The DataKit context follows SwiftData's `ModelContext`: it is deliberately + non-`Sendable`, belongs to one concurrency context, and exposes stable model + identities there. UIKit chooses MainActor; a custom actor chooses itself. + Neither may share the mutable graph with the other. +- Like `URLSessionTask.cancel()`, cancellation may race with already produced + events. Therefore event-scope return, not the initial cancellation request, is + the deterministic teardown boundary. + +The analogs guide lifecycle and naming; they do not justify mirroring unrelated +framework API surface. + +## ProxyKit API Sketch + +The exact generic spelling may change to satisfy Swift 6.3 ownership checking, +but the visible concepts and lifecycle are fixed by this gate. + +```swift +public final class WebInspectorProxy: Sendable { + public struct Configuration: Sendable { + public var responseTimeout: Duration + public var bootstrapTimeout: Duration + + public init( + responseTimeout: Duration = .seconds(10), + bootstrapTimeout: Duration = .seconds(10) + ) + } + + @MainActor + public init( + attachingTo webView: WKWebView, + configuration: Configuration = .init() + ) async throws + + public var page: WebInspectorPage { get } + public func close() async + public func waitUntilClosed() async throws + + @MainActor + public static func withAttachment( + to webView: WKWebView, + configuration: Configuration = .init(), + _ operation: @MainActor (WebInspectorProxy) async throws -> Result + ) async throws -> Result +} + +public struct WebInspectorPage: Sendable { + public struct Generation: Hashable, Sendable { /* opaque */ } + + public var generation: Generation { get async throws } + public var dom: DOM { get } + public var css: CSS { get } + public var network: Network { get } + public var console: Console { get } + public var runtime: Runtime { get } + public var page: Page { get } +} + +public enum WebInspectorEventBufferingPolicy: Sendable { + case bounded(Int) + case unbounded +} + +public enum WebInspectorPageEvent: Sendable { + case reset(WebInspectorPage.Generation) + case event(WebInspectorPage.Generation, Element) +} + +public struct WebInspectorScopeError: Error { + public let operationError: any Error + public let cleanupError: any Error +} + +extension Network { + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream< + WebInspectorPageEvent, + any Error + > + ) async throws -> Result + ) async throws -> Result +} +``` + +`DOM`, `CSS`, `Network`, `Console`, `Runtime`, and `Page` are concrete, +target-scoped value handles as well as the namespaces for their protocol value +types. There is no nested `Client` layer. A package-only +`WebInspectorDomainHandle` protocol owns command dispatch, and the closed-set +`WebInspectorEventDomainHandle` refinement owns structured event registration +and extraction. These protocols are deliberately not public: ProxyKit does not +support consumer-defined WebKit domains, so public conformance would promise an +extension point the connection core cannot honor. Each concrete event handle +keeps only the thin public `withEvents` forwarder required by Swift access +control. + +This is closed-set implementation reuse, not public extensibility. Consumer +code receives concrete struct handles and calls their typed operations directly; +the package protocols and witnesses exist only to keep dispatch and +`withEvents` mechanics identical across the known domains. + +Only the outer domain handles change from namespace enums to structs; nested +sum types such as `DOM.Event` and `Network.Event` remain enums. `DOM`, `CSS`, +`Network`, `Console`, and `Runtime` expose the same structured event scope, +while `Page` is command-only. There is +no separate public `enable()`, `disable()`, cold `events`, or subscription +barrier. The closure is the capability lease. DataKit holds capabilities for the +attachment lifetime through the acyclic ordered model-feed driver described +below; direct consumers normally consume a scope inline. The stored driver task +does not strongly capture its context, actor, or domain owners. + +The low-level implementation registers a bounded subscriber before sending the +first enable command. On scope exit it awaits lease release. A second consumer +increments the count without sending another enable; only the final release +sends disable. Cancellation during enable completes enable, then balances it +with disable before returning. + +`withEvents` inherits the caller's actor through its `isolation` parameter; its +nonescaping operation closure is deliberately not `@Sendable`. This permits a +MainActor UI or another actor-owned consumer to use isolated state without +unsafe captures. `.bounded` requires a strictly positive capacity and rejects +zero or negative values as a programmer error. + +Scope cleanup follows one rule across domains: body success plus final-disable +failure throws the disable error; body failure plus successful cleanup rethrows +the body error; if both fail, `WebInspectorScopeError` preserves both with the +body error primary. Destruction of the old physical target makes its local lease +release complete without sending disable to the new generation. + +Every scope begins with `.reset(currentGeneration)`. If it acquired the first +lease, any enable-time replay is already buffered after that marker before the +operation closure runs. If the capability was already enabled, the scope is +future-only after the marker; it does not receive another consumer's earlier +events. A physical binding change emits a new `.reset` before any event from the +new binding. + +DataKit does not infer readiness from the public stream. ProxyKit provides one +package-level ordered model feed whose internal records carry transport sequence, +page generation, physical target identity, and explicit synchronization +boundaries: + +```swift +package enum ConnectionModelFeedRecord: Sendable { + case reset(WebInspectorPage.Generation) + case targetSnapshot( + generation: WebInspectorPage.Generation, + through: UInt64, + snapshot: ModelTargetSnapshot + ) + case event( + generation: WebInspectorPage.Generation, + sequence: UInt64, + payload: ModelProtocolEvent + ) + case domDocumentInvalidated( + generation: WebInspectorPage.Generation, + sequence: UInt64, + target: ModelTarget, + documentEpoch: ModelDocumentEpoch + ) + case replayComplete( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + through: UInt64 + ) + case bootstrapSnapshot( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + sequence: UInt64, + payload: ModelBootstrapSnapshot + ) + case bootstrapComplete( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + through: UInt64 + ) + case synchronizationComplete( + generation: WebInspectorPage.Generation, + through: UInt64 + ) +} + +package struct ModelTarget: Sendable { + let id: WebInspectorTarget.ID + let kind: WebInspectorTarget.Kind + let frameID: FrameID? + let parentFrameID: FrameID? +} + +package struct ModelTargetSnapshot: Sendable { + let currentPageID: WebInspectorTarget.ID + let targets: [ModelTarget] +} + +package struct ModelDocumentEpoch: Hashable, Sendable { /* opaque */ } +package enum ModelProtocolEvent: Sendable { /* typed payload except document invalidation */ } +package enum ModelBootstrapSnapshot: Sendable { /* target + epoch + typed snapshot */ } +package enum ModelDomain: Hashable, Sendable { /* configured domains */ } +package enum ConnectionModelFeedError: Error, Sendable { + case bootstrapFailed(domain: ModelDomain, message: String) + // exclusive-use and consumer-lifecycle cases omitted +} +``` + +The current transport slice implements the lossless exclusive feed, initial +`reset`/`targetSnapshot`, future target lifecycle deltas, future events for +configured domains, transactional capability leases, enable-replay boundaries +for CSS, Network, Console, and Runtime, DOM snapshot bootstrap, and one +binding-level synchronization boundary. Configuration is normalized once at +registration: CSS implies DOM, while Network alone does not. Capability +acquisition uses that same normalized set in the deterministic domain order +DOM, CSS, Network, Console, Runtime. Completion records remain reply-driven and +may interleave across domains; the single `synchronizationComplete` record is +the only all-domain-ready barrier. DOM's capability lease itself is local and +sends no `DOM.enable`; the separate bootstrap owner sends `DOM.getDocument`. +The feed is registered and its initial records are published before the first +capability await. DataKit installs and starts the sole feed driver through that +registration barrier before capability activation begins, so enable-time replay +is consumed while `openModelFeed` is still awaiting the wire. The all-domain +`synchronizationComplete` marker remains gated until activation has completed; +therefore `.attached` still implies that model commands are active. +`openModelFeed` returns only after every configured capability is active. An +acquisition failure or cancellation releases the successful prefix in reverse +order before returning. For an empty +configured-domain set the feed also emits `synchronizationComplete`. +An inspected target's rejection of a configured domain activation is reported +as `bootstrapFailed` with the `ModelDomain` already owned by the acquisition +loop; Core never recovers that domain by parsing the rejected method string. +Cancellation, page disappearance, protocol violation, and connection/transport +termination keep their existing categories. If rollback cleanup also fails, +`WebInspectorScopeError` retains the typed bootstrap rejection as its operation +error and the independent cleanup error as its cleanup error. + +For a successful wire `enable` reply, the connection core publishes one +`replayComplete` for each configured model-domain owner of that physical +capability. Publication is synchronous in reply processing: all earlier inbound +events have already been projected into the feed, the marker uses the reply's +current transport-sequence watermark, and only then can the capability promise +resume `openModelFeed`. Shared leases do not duplicate a model-domain marker, +rejected or superseded operations publish none, and a replacement physical +binding publishes its marker in the replacement generation. Local DOM never +publishes an invented replay boundary. + +DOM bootstrap completion and all normalized replay completions are tracked per +binding generation. Only after every configured domain completes does the core +emit one `synchronizationComplete`; a later document epoch or added frame may +emit another DOM bootstrap boundary, but never a second synchronization record +for that generation. Reset and retarget replace the completion state, so a late +reply from an old operation cannot complete the new binding. This distinction +prevents wire replay completion from being mistaken for complete model +readiness. + +The target snapshot contains the physical current page first, followed by its +relevant committed frame targets in deterministic parent-before-child order. +Its `through` watermark subsumes target lifecycle events at or before that +sequence; every later lifecycle delta has a strictly greater sequence. If the +feed opens while the page is unavailable, its reset reserves the generation +that the next current-page binding will use. The later target snapshot and +empty-domain synchronization record use that same generation and do not emit a +duplicate same-sequence `targetCreated` delta. + +The feed is an unbounded, oldest-first, single-consumer sequence. This is an +explicit internal exception to the public stream default: it is the only +authoritative snapshot/delta path, WebKit provides no producer backpressure, +the upstream ingress queue can already accumulate messages, and Network/Console +models intentionally retain their semantic records. An arbitrary pending-record +limit would therefore break continuity without establishing a process memory +bound. The consumer-registration barrier still prevents capability activation +from creating a producer-only interval and reduces transient backlog. Iterator +cancellation or handle abandonment terminates that mailbox synchronously while +retaining the exclusive connection claim. If the producer later attempts +another enqueue to that terminated mailbox, that enqueue poisons the connection; +an explicit `close()` before another enqueue remains a clean shutdown. +Explicit `ConnectionModelFeed.close()` is the sole feed-close surface. It +releases configured capabilities in reverse acquisition order and releases the +claim only after clean quiescence, allowing a replacement feed. Concurrent and +repeated close calls share that completion. A capability cleanup failure poisons +the mailbox and terminates the connection, because the physical enabled state +cannot safely be reused without a logical owner. +Dropping the handle synchronously finishes its mailbox but intentionally keeps +the connection claimed until connection close. Explicit connection close +finishes the feed normally only after transport close work reaches quiescence; +fatal and protocol termination fail it. + +The model feed and direct consumption are mutually exclusive. Direct command +admission, including transport-local `DOM.enable`, claims the connection before +the local/wire split; once a direct command or structured scope is admitted, a +feed cannot reconstruct the missed prefix. While a feed owns the connection, +ordinary direct commands and structured scopes fail with +`WebInspectorProxyError.connectionInUse`. Legacy cold passive streams are +migration-only: starting one while the feed is open is a programmer error, and +they must be deleted rather than adapted around this ownership boundary. + +Model code receives a package-only `ConnectionModelCommandAuthorization` from +the ordered feed application boundary. It contains the exact feed identity and +binding generation, plus a physical target/document epoch for document-scoped +work. The authority is propagated as a value through page/domain handles, +command encoding, and the core; the core registration remains the only source +of configured domains and readiness. `Page`, Network, Console, and Runtime +commands wait for binding `synchronizationComplete`. DOM, CSS, and Inspector +picker work additionally waits for that target's latest accepted DOM bootstrap. +An old generation or document epoch fails locally as `staleIdentifier`; a +foreign/closing feed, unconfigured domain, or missing document authorization +fails with a typed package error and never reaches the wire. Binding-only work +deliberately ignores an otherwise stale document field. + +Enable/disable, `DOM.getDocument`, and `Inspector.initialized` remain +connection-owned operations and cannot be forged through model authority. +Capability leases and DOM bootstrap tasks are their only owners. Model commands +have Core-owned readiness waiters, tasks, and pending-reply purposes. Retarget +invalidates all old-generation main/frame work without touching a provisional +new-target direct reply; `DOM.documentUpdated` invalidates only DOM/CSS/Inspector +work for the old target epoch while binding-level work continues. Feed close +first rejects admission, drains those waiters/tasks/replies, then releases +capabilities and the exclusive claim. Terminal and overflow teardown perform the +same drain before transport detach. + +Core ownership of a task handle does not permit the task's async frame to retain +`ConnectionCore` across readiness or wire suspension. A model-command runner +keeps only a weak Core reference between bounded, non-suspending actor hops. +Each hop validates and commits one Core-owned state transition, then returns a +Sendable decision or effect before the runner awaits an externally owned, +synchronized operation signal or reply promise. It must not bind Core strongly +once and call an actor method that remains suspended until readiness or a wire +reply. Explicit close rejects admission, synchronously signals every operation, +cancels the task handles, and awaits their completion before releasing the feed +claim. Core's isolated deinitializer can only repeat the synchronous +cancellation/signalling backstop; it cannot await those tasks. + +The later DataKit feed consumer will apply records serially. Reaching +`replayComplete` therefore proves that every earlier event through that +watermark has been applied to the model, not merely placed in a stream buffer. +After all configured domain boundaries, `synchronizationComplete` proves the +physical binding itself is ready. This explicit record is required for an empty +domain configuration and for unavailable-to-ready rebinding, where no +domain-specific marker can carry readiness. The feed is package-only because +its mixed-domain payload and acknowledgement boundaries are model-adapter +mechanics, not a direct ProxyKit consumer concept. +The feed registers its initial reset and target snapshot before acquiring any +configured capability. Capability acquisition is transactional: if one enable +fails or the task is cancelled, the core releases every capability acquired for +that attempt in reverse order and fails the feed open. If rollback itself cannot +prove quiescence, the core terminates the connection. A DataKit model context +takes exclusive ownership of its Proxy connection, so it is always the first +model-feed subscriber and does not depend on a late subscriber receiving +historical replay. + +DOM readiness uses the same ordered feed even though DOM has no enable-time +replay. After the feed is registered, the core issues `DOM.getDocument` +sequentially for the physical current page and each committed relevant frame in +the target snapshot's deterministic order. Its reply arrival sequence becomes +a `bootstrapSnapshot` record; DOM mutation +events at or before that watermark are subsumed by the returned full snapshot, +and later events follow it in sequence order. If `DOM.documentUpdated` advances +the document epoch between request and reply, the snapshot is discarded and +the command is retried. A valid snapshot will be followed by `bootstrapComplete`. +Every configured domain must reach either its replay or bootstrap completion +boundary before ProxyKit emits `synchronizationComplete`; DataKit applies that +final record before attachment or retarget synchronization becomes ready. A +later `DOM.documentUpdated` advances that physical target's DOM epoch and starts +the same ordered snapshot bootstrap again rather than exposing an empty tree as +ready state. A frame added before initial synchronization joins the outstanding +bootstrap set; a destroyed, superseded, or old-epoch reply is stale and cannot +publish. An inspected target's rejection of a required initial or refresh +`DOM.getDocument` is terminal and poisons the package mailbox with +`bootstrapFailed(domain: .dom, message:)`; malformed data and transport or +connection failure remain protocol/connection failures. Bootstrap commands are +Core-owned tasks: close, rollback, retarget, and terminal teardown cancel and +await them, and a failed feed enqueue is an operation-terminal result that +cannot advance to the next target. + +For a feed whose normalized configuration includes DOM, every relevant +main-page or frame-target `DOM.documentUpdated` is projected as +`domDocumentInvalidated` after Core advances that target's +`ModelDocumentEpoch`. The record uses the inbound event sequence and is enqueued +before bootstrap starts or any later DOM/CSS delta for that target. It is the +model feed's only document-invalidation projection; an ordinary +`ModelProtocolEvent.dom(.documentUpdated)` is not also published. Public +structured event scopes keep their existing projection, including the +intentional filtering of frame-target `documentUpdated` from the semantic +current-page scope. + +The DataKit reducer treats this record as the authoritative per-target boundary. +It immediately invalidates that target's DOM/CSS command authority and identity +state, then ignores target DOM/CSS deltas until a `bootstrapSnapshot` with the +same generation, target, and document epoch is applied. That snapshot atomically +replaces the target document and reauthorizes it; only later-sequence deltas may +mutate the replacement. A stale or skipped invalidation epoch is a protocol +failure rather than a guessed merge. + +One-off commands internally acquire their declared prerequisites for the +duration of the command. Long-lived event/model state holds a structured event +scope. Dependency declarations are centralized, for example: + +```text +Network events -> Network +Console events -> Console +Runtime events -> Runtime +CSS events/queries -> DOM + CSS +Element picker -> DOM + Inspector + inspect-mode lease +``` + +Console is deliberately independent of the Runtime capability. Console message +remote objects belong to WebKit's internal `"console"` object group, not to a +DataKit-created `RuntimeObjectGroup`. `Console.messagesCleared` is emitted only +after WebKit has released that group. The reducer therefore resets Console +messages and makes their local `RuntimeObject` values stale without acquiring +Runtime or sending `Runtime.releaseObjectGroup`. + +Element picking becomes a dedicated scoped operation rather than a Boolean +`DOM.setInspectMode` that cannot represent multiple users: + +WebKit owns picker selection on the physical main-page agents. Its `Inspector` +protocol excludes frame targets, `FrameInspectorController` installs no +`InspectorAgent`, and the frame `DOM.requestNode` implementation is an explicit +unsupported stub. Consequently an `Inspector.inspect` payload is always decoded +against the main page, `DOM.requestNode` is sent to that same page DOM agent, and +the returned node identifier remains in the unscoped main-page DOM namespace. +There is no frame-origin fallback or synthetic frame scope. WebKit may emit the +`DOM.setChildNodes` path needed by `requestNode` before its reply; after that +reply the core publishes the picker selection with a new ordered event sequence. + +The picker lease registers before `Inspector.enable`, ignores enable-time pending +inspect replay until `DOM.setInspectModeEnabled(true)` succeeds, and becomes +active only after that reply. Release first disables inspect mode and then +balances the Inspector capability. The core sends `Inspector.initialized` once +per Inspector-capable physical page generation, not once per picker lease. A +page generation, DOM document epoch, or picker lease change invalidates an +in-flight resolution instead of retrying it against a different page or +inventing an `unknown` DOM event. + +```swift +try await page.dom.withElementPicker { selections in + for try await item in selections { + switch item { + case .reset: + resetSelectionState() + case .event(_, let selection): + consume(selection) + } + } +} +``` + +### Target semantics + +`WebInspectorPage` is a logical current-page route that survives navigation and +process replacement. The physical target record and synthetic `.currentPage` +route become package/internal implementation details. Public command DTO IDs +such as `FrameID`, `DOM.Node.ID`, and `Network.Request.ID` remain opaque and +Sendable, but internally carry their originating page generation. Passing an +old scoped ID to a current-generation command fails locally with +`staleIdentifier`; it is never sent to the replacement target. + +When no physical page is temporarily committed, a command fails with +`pageUnavailable`; it does not guess a stale target. During commit the core +performs the following ordered transport transition. The current ProxyKit slice +establishes its synchronous reset, physical target snapshot, future-delta +prefix, capability-owner reconciliation onto the new physical target, DOM +bootstrap, wire capability replay watermarks, and the binding-level +synchronization record described above. DataKit application of that feed +remains a later slice: + +1. stop admission of new target-scoped commands and increment page generation; +2. publish `.reset(newGeneration)` to public scopes and the package model feed; +3. invalidate old scoped IDs/replies, install the committed registry binding, + and retarget only pending replies that belong to the committing provisional + target; +4. reconcile desired logical capability leases against fresh physical + activation state for that binding while continuing to buffer provisional + and enable-time events with transport sequence numbers; +5. release the new-generation buffer in original transport order, then publish + each capability's replay/bootstrap-complete watermark; +6. publish `synchronizationComplete`, mark the binding ready, and resume command + admission. + +No old-generation event can be enqueued after the reset boundary. A destroyed +old target is not sent disable commands through the new binding; its activation +state is discarded by generation. Queue overflow during the transition is a +connection failure, never a partial release. DataKit clears all binding-scoped +state when it applies the reset record and cannot observe new replay before that +clear. If capability reacquisition fails, the connection and model feed fail +instead of exposing a partially ready generation. + +The capability registry stores desired logical lease count separately from +physical activation state `(generation, enabling/enabled/disabling)`. Because +an actor can reenter while awaiting enable, completion always reconciles against +the latest desired count and generation. A final release during retarget may +finish an in-flight enable only to balance it with disable; a late acquire joins +the current desired count; completion from an old generation is discarded and +can never mark the new binding enabled. + +### Failure and buffering contract + +`WebInspectorProxyError` will distinguish at least: + +```swift +public enum WebInspectorProxyError: Error, Sendable { + case closed + case pageUnavailable + case staleIdentifier + case commandRejected(method: String, message: String) + case protocolViolation(String) + case eventBufferOverflow(capacity: Int) + case transportFailure(String) +} +``` + +- A known event that fails typed decoding terminates the connection with + `protocolViolation`. +- An unknown method remains representable as `RawEvent` where the existing API + promises unknown-event delivery. +- Malformed root JSON, ingress overflow, and provisional-target queue overflow + terminate the connection; they are never silently dropped. +- Protocol event streams use bounded oldest-first buffering. The first drop + terminates only that subscriber with `eventBufferOverflow`; peer subscribers + continue. +- The package model feed is lossless and unbounded because it is the sole + authoritative snapshot/delta path. It never drops or fabricates a full-domain + resynchronization. +- Coalescible state notifications use newest-one buffering. +- Unexpected disconnect throws from event scopes. Explicit close ends scopes + normally after all close work completes. +- Unbounded buffering is not a public default; the exclusive authoritative + model feed is the documented internal exception. + +The implementation uses standard `AsyncThrowingStream` and +throws concrete `WebInspectorProxyError` values. A custom typed-failure sequence +is rejected because Swift 6.3's stream construction APIs are restricted to +`Failure == Error` and the extra implementation surface does not improve the +consumer story. + +### Proxy consumer migration + +```swift +// Before: enable and iteration are separate, so initial replay can be lost. +let target = try await proxy.waitForCurrentPage() +try await target.network.enable() +for await event in target.network.events { + consume(event) +} + +// After: registration, enable, iteration, and disable are one scope. +try await proxy.page.network.withEvents { events in + for try await item in events { + switch item { + case .reset: + resetNetworkState() + case .event(_, let event): + consume(event) + } + } +} +``` + +## DataKit API Sketch + +DataKit introduces `WebInspectorModelContext` as a non-`Sendable` semantic model +owner. It may be constructed before entering an actor, but attachment binds its +mutable graph to the actor that calls `attach(to:)`. The UIKit +`WebInspectorSession` stores one such context on MainActor; a custom actor may +store and attach a different context using the same public API. + +```swift +public enum WebInspectorModelError: Error, Equatable, Sendable { + case detached + case synchronizing + case domainNotConfigured(WebInspectorModelContext.Domain) + case staleModel + case commandRejected(method: String, message: String) +} + +@Observable +public final class WebInspectorModelContext { + public struct PageGeneration: Hashable, Sendable { /* opaque */ } + + public struct Configuration: Sendable { + public let domains: Set + public init( + domains: Set = [.dom, .network, .console, .runtime, .css] + ) + } + + public struct Domain: Hashable, Sendable { + public static let dom: Domain + public static let network: Domain + public static let console: Domain + public static let runtime: Domain + public static let css: Domain + } + + public enum ConnectionFailure: Equatable, Sendable { + case closed + case pageUnavailable + case protocolViolation(String) + case transport(String) + } + + public enum Failure: Error, Equatable, Sendable { + case connection(ConnectionFailure) + case bootstrap(domain: Domain, message: String) + } + + public enum TransitionError: Error, Equatable, Sendable { + case superseded + case closed + } + + public enum State: Equatable, Sendable { + case detached + case attaching + case synchronizing(PageGeneration) + case attached + case detaching + case closed + case failed(Failure) + } + + public private(set) var state: State + public private(set) var attachmentGeneration: UInt64 + public private(set) var pageGeneration: PageGeneration? + + public let configuredDomains: Set + public var domTree: DOMTreeController { get throws } + public var rootDOMNode: DOMNode? { get throws } + public var selectedDOMNode: DOMNode? { get throws } + public var isElementPickerEnabled: Bool { get throws } + public var runtimeContexts: [RuntimeContext] { get throws } + + public init(configuration: Configuration = .init()) + + @MainActor + public convenience init( + attachingTo webView: WKWebView, + configuration: Configuration = .init(), + proxyConfiguration: WebInspectorProxy.Configuration = .init() + ) async throws + + public func attach( + to proxy: WebInspectorProxy, + isolation: isolated (any Actor) = #isolation + ) async throws + + public nonisolated(nonsending) func detach() async + public nonisolated(nonsending) func close() async + public nonisolated(nonsending) func reload( + ignoringCache: Bool = false + ) async throws + + public func domNode(id: DOMNode.ID) throws -> DOMNode? + public func domTree(rootedAt node: DOMNode) throws -> DOMTreeController + public nonisolated(nonsending) func requestDOMChildren( + of node: DOMNode, + depth: Int = 1 + ) async throws + public func selectDOMNode( + _ node: DOMNode?, + reveal: DOMRevealPolicy = .selectAndScroll + ) throws + public nonisolated(nonsending) func copyText( + _ kind: DOMNode.CopyTextKind, + for node: DOMNode + ) async throws -> String + public func selectorPath(for node: DOMNode) throws -> String + public func xPath(for node: DOMNode) throws -> String + public nonisolated(nonsending) func setDOMAttribute( + _ name: String, + value: String, + on node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome + public nonisolated(nonsending) func setOuterHTML( + _ html: String, + of node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome + public nonisolated(nonsending) func removeDOMNodes( + _ nodes: [DOMNode], + undo: WebInspectorUndoPolicy = .automatic + ) async throws + -> DOMMutationOutcome + public nonisolated(nonsending) func highlightDOMNode( + _ node: DOMNode + ) async throws + public nonisolated(nonsending) func hideDOMHighlight() async throws + public nonisolated(nonsending) func setElementPickerEnabled( + _ enabled: Bool + ) async throws + + public nonisolated(nonsending) func networkRequests( + matching query: NetworkQuery = .init() + ) + async throws -> WebInspectorFetchedResults + public nonisolated(nonsending) func consoleMessages( + matching query: ConsoleQuery = .init() + ) + async throws -> WebInspectorFetchedResults + public func networkRequest(id: NetworkRequest.ID) throws -> NetworkRequest? + public nonisolated(nonsending) func clearNetworkRequests() async + public nonisolated(nonsending) func clearConsoleMessages() async throws + public nonisolated(nonsending) func responseBody( + for request: NetworkRequest + ) async throws -> NetworkBody + + public func withRuntimeObjectGroup( + named: String? = nil, + isolation: isolated (any Actor) = #isolation, + _ operation: (RuntimeObjectGroup) async throws -> Result + ) async throws -> Result + + public nonisolated(nonsending) func cssStyles( + for node: DOMNode + ) async throws -> CSSStyles + public nonisolated(nonsending) func refreshCSSStyles( + for node: DOMNode + ) async throws + public nonisolated(nonsending) func setCSSProperty( + _ property: CSSStyleProperty, + enabled: Bool, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public nonisolated(nonsending) func setCSSDeclarationText( + _ text: String, + for property: CSSStyleProperty, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public nonisolated(nonsending) func setCSSRuleSelector( + _ selector: String, + for rule: CSSStyleRule, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? + public nonisolated(nonsending) func setCSSStyleSheetText( + _ text: String, + for styleSheetID: CSS.StyleSheet.ID, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? +} +``` + +`Domain` is a closed-construction public value: consumers can combine and store +the five known static members but cannot manufacture a domain the model feed +cannot support or enumerate cases through a public conformance. `Page` remains +a ProxyKit command/lifecycle concern and is not a configured DataKit model +domain. The package-only exhaustive `ModelDomain` enum owns normalization, +dependency expansion, ordering, and every switch. A +package-only protocol or witness may share mechanics among those cases, but +DataKit promises neither consumer-defined domains nor public conformances. + +`attach(to:)` returns only after every configured domain has acquired its event +scope and the single ordered feed has applied every configured domain's initial +replay or bootstrap completion boundary followed by the binding-level +`synchronizationComplete`. It also establishes the context's owner actor; the +context must already be stored and used on that actor and may not be transferred +after attachment. A second attach to the same proxy while an attach is in flight +joins that transition. A physical retarget moves the state to +`.synchronizing(newGeneration)` until the same readiness condition is met. +Attaching a different proxy supersedes the old transition, makes its waiters +throw `TransitionError.superseded`, awaits complete teardown, and begins the new +attachment. `detach()` is idempotent and leaves the context reusable on the same +actor. Retry is allowed from `.failed`; `close()` is idempotent and terminal. + +The supplied Proxy connection is exclusively owned by the context for the model +attachment lifetime. The package model feed must be its first model consumer; +an already claimed feed fails fast instead of manufacturing missing replay. +UIKit creates that Proxy on MainActor before calling the same context API. A +custom actor receives the Sendable Proxy from its MainActor attachment host and +then calls `attach(to:)` on its actor-confined context. + +An attach waiter does not own the shared context resource: cancelling one caller +cancels only that wait. Explicit `detach()`/`close()` changes resource state. +Every transition is serialized by one owner-confined state machine despite +actor reentrancy. Failure is carried only by `State.failed`; there is no +separately mutable optional error. + +The context never retains its actor. Attachment stores only its weak identity +for a feed-boundary executor precondition and creates one detached feed-driver +task that weakly captures the caller actor plus a private delivery bridge. +The bridge is the sole DataKit `@unchecked Sendable` escape hatch: it owns no +semantic or lifecycle state and holds only a weak context reference. For each +Sendable record, the driver obtains the weak actor, calls +`apply(record:isolation:)` to hop to it, and only then dereferences the weak +context. `detach()` and `close()` cancel and await the driver; ordinary `deinit` +cancels it synchronously. This prevents both +`actor -> context -> actor` and `actor -> context -> task -> actor` cycles. +The bridge's weak context is bound exactly once during first attachment by a +method isolated to the owner actor. Every later method that reads it has the same +isolated actor parameter, and the bridge is private and never escapes the +Context/driver pair. Strict SIL concurrency fixtures and +executor/deallocation tests lock down those unchecked invariants. + +Synchronous graph APIs need no owner argument. Async graph APIs are explicitly +`nonisolated(nonsending)` because this package does not enable +`NonisolatedNonsendingByDefault`; they resume on the caller's executor and may +therefore continue to use the non-Sendable graph after suspension. The isolated +parameter is limited to attachment, scoped caller-isolated closures, and the +private delivery hop rather than being repeated through every helper. + +A domain not present in the normalized configuration does no startup work. Its +internal store remains reset and facade operations throw +`domainNotConfigured`. +`configuredDomains` is inspectable before attachment, and domain-specific +throwing getters follow the same rule; `nil` from a configured DOM getter means +"no current document/selection", never "this domain was silently disabled". +Semantic dependencies are explicit: CSS includes the DOM model because its +public API consumes `DOMNode`; protocol-only dependencies such as Inspector for +the picker remain private capabilities. In particular, Network-only startup +does not enable DOM, CSS, Console, Runtime, or Inspector, and a Console-only +configuration does not enable Runtime. + +One context-wide generation would be too coarse. Each model owns the epoch that +defines its identities: + +| Lifetime boundary | Affected owners | +| --- | --- | +| new attachment | every domain; collections start empty | +| physical page binding/process replacement | every domain; old scoped IDs become stale | +| same-binding document navigation / `DOM.documentUpdated` | DOM and CSS document epochs reset before later DOM/CSS events | +| Runtime execution-context clear | Runtime context/remote-object epoch only | +| explicit Network clear | Network collection revision; retained request handles become stale for body lookup | +| ordinary same-binding navigation | Network history remains unless WebKit emits its own clear; new requests append | +| Console clear event or command | Console collection revision and local validity of Console-originated remote objects; WebKit already owns wire release | + +The ordered model feed applies each boundary and its affected store resets before +the next event. Public result/resource types may expose read-only revisions for +rendering, but stale validation uses the internal owning store's epoch rather +than the context's attachment counter. + +### Internal stores and the public facade + +No public `WebInspectorDOMModel`, `WebInspectorNetworkModel`, +`WebInspectorConsoleModel`, `WebInspectorRuntimeModel`, or corresponding +`*Store` type is introduced. Such types would only add a second navigation path +to state already represented by identity models, result snapshots, and scoped +resources. + +The package-internal stores in the owner map are justified only by moving whole +write sets out of `WebInspectorContext`: + +- `DOMStateStore` owns the node identity registry, document epoch, root, + selection, tree projection registrations, picker/highlight state, and undo + binding. +- `NetworkRequestStore` and `ConsoleMessageStore` own canonical identity maps, + collection epochs, weak fetched-results registrations, and compact Sendable + records passed to their query indexes. +- `RuntimeStateStore` owns execution-context identity and object-group + membership. + +CSS does not introduce another store. A `CSSStyles` resource is keyed by a +registered `DOMNode`, becomes stale with that node's document epoch, and is +discarded with the node, so its membership and lifetime belong to +`DOMStateStore`. `WebInspectorModelContext` coordinates awaited CSS protocol I/O +and applies each result back through that owner; it does not keep a parallel CSS +identity map or CSS epoch. + +They are implementation owners, not observable facade objects. They do not +mirror one another, and a store is not added until the corresponding Context +properties and writers are deleted. `WebInspectorModelContext` forwards public +operations to the sole owner without caching a second copy of domain state. + +The existing public resource/result contracts remain the domain surface: + +| Domain | Public values/resources retained | +| --- | --- | +| DOM | `DOMNode`, `DOMTreeController`, `DOMTreeSnapshot`, `DOMTreeUpdate`, `DOMTreeDelta`, reveal/reset values | +| Network | identity-preserving `NetworkRequest`, `WebInspectorFetchedResults`, fetched-results snapshot/transaction | +| Console | identity-preserving `ConsoleMessage`, `WebInspectorFetchedResults`, fetched-results snapshot/transaction | +| Runtime | `RuntimeContext`, evaluation values, and scoped `RuntimeObjectGroup` | +| CSS | `CSSStyles`, sections, rules, declarations, properties, and computed properties | + +Every mutable identity/resource/result class in this table—including +`DOMNode`, `DOMTreeController`, `NetworkRequest`, `ConsoleMessage`, and +`CSSStyles`—is non-`Sendable` and belongs to its model context's actor. Only its +immutable Sendable snapshot/delta/record values cross to index, transport, or a +different consumer actor. A context owned by a custom actor cannot vend its +identity handles to UIKit; that consumer creates a MainActor-owned context or +passes value snapshots instead. + +DOM tree topology therefore also remains snapshot-plus-delta based; it is not +replaced by an observable node array. `model.domTree` is the stable root tree, +and `model.domTree(rootedAt:)` produces a live subtree result. Selection reveal +intent is ordered with tree updates and consumed explicitly by UIKit so +`.none`, `.selectOnly`, and `.selectAndScroll` retain their documented meaning. +Tree subscription continues to register and enqueue its initial snapshot as one +synchronous owner operation. Its update broker becomes bounded: if a subscriber +falls behind, pending deltas coalesce to one current full snapshot (with an +explicit coalescing reset reason) before later deltas, rather than accumulating +an unbounded queue or applying a delta across a gap. + +Each Sendable DOM delta is self-contained across actors: it carries base and new +revisions, detached node-value upserts, removals, updated parent/child topology, +the current root and selection, and optional reveal intent. One semantic +mutation publishes one revision. Reveal is not a separately ordered stream. + +Every DOM mutation returns a `DOMMutationOutcome` containing applied IDs, +per-node failures, and an optional document-epoch-bound `DOMUndoCapability`. +UIKit registers that capability with `UndoManager`; a custom UI can use the same +public contract. Undo/redo after document replacement throws `staleModel`. +Model identity convenience methods no longer dispatch commands themselves, and +every mutation awaits its protocol result. CSS lookup never changes DOM +selection, CSS mutations return the same optional undo capability, and +fire-and-forget CSS mutation is removed. + +`cssStyles(for:)` awaits the initial load and returns the node's stable +`CSSStyles` resource without selecting that node. Later protocol changes mark +the resource `.needsRefresh`; they do not start an invisible background task. +A visible UIKit/custom consumer observes that phase and calls the awaited +`refreshCSSStyles(for:)`. A cached but hidden controller does nothing, and on +its next appearance refreshes once if stale. Thus presentation lifecycle owns +refresh demand without a global hydration Boolean, lease, or retained task. + +### CSS declaration identity and rendering + +`CSSStyles` owns section/property membership and order. Each +`CSSStyleProperty` is a non-`Sendable`, `@Observable` identity model belonging +to the same caller actor as its `WebInspectorModelContext`: + +```swift +@Observable +public final class CSSStyleProperty: Identifiable { + public let id: ID + public private(set) var name: String + public private(set) var value: String + public private(set) var text: String? + public private(set) var status: Status + public private(set) var isEditable: Bool + public private(set) var isModifiedByInspector: Bool + public private(set) var isMutationPending: Bool +} +``` + +WebKit's property ID is a style ID plus array index, so it is a backend +coordinate rather than semantic declaration identity. Refresh and +`CSS.setStyleText` results mutate existing properties in place only while the +ordered declaration-name topology is unchanged. If insertion, removal, rename, +or reordering changes that topology, replacement property objects make every +old handle stale even when a new declaration reuses its raw positional ID. +Changed duplicate-name declarations are also replaced unless the current +mutation explicitly owns that declaration; the protocol cannot otherwise prove +continuity. Setters compare the incoming field before writing, so an accepted +toggle publishes changes only from the affected declaration and unchanged +sibling properties do not emit Observation invalidations. + +Inspector edit baselines are context-owned style state, not selected-node or +positional-ID state. Every node-specific `CSSStyles` resource consults the same +store using WebKit's target-scoped style ID, so a shared stylesheet rule keeps +the same modified declarations when inspected through another matching DOM +node. This follows Web Inspector's `CSSManager` ownership of modified styles by +style ID. When a style's topology changes, a baseline follows its declaration +to the new raw property ID only when both the recorded and incoming +`(style ID, property name)` are unique. This preserves modified highlighting +across shorthand expansion such as `inset` producing implicit longhands, while +ambiguous duplicate declarations fail closed instead of attributing an edit to +the wrong row. Only a successful inspector mutation may reconcile declaration +topology or retire a baseline after returning to its original value; ordinary +load responses are projections and cannot overwrite shared edit history. A +main-document reset clears the whole context-owned baseline store, while child +frame invalidation and target retirement clear only that target's scoped +baselines. + +The UIKit property row observes its property directly. Its switch state, +declaration text, modified background, and accessibility value update through +that observation while the cell and property identities remain stable. A +submitted mutation sets `isMutationPending` only on the submitted property, so +only that switch becomes temporarily unavailable. Other declaration switches +keep their declaration-specific enabled state. + +The selected-style observation callback is a dependency-sampling boundary, not +the render pass. It reads the selected node's `elementStyles`, resource phase, +section topology, and the property fields that determine CSS-variable row +visibility, then coalesces rendering onto the next MainActor turn. Individual +visible rows observe their own remaining property content. The deferred render +reads the latest resource state after Observation has installed replacement +tracking. Consequently, a `.loaded` commit arriving while the preceding +`.loading` or `.needsRefresh` callback is running cannot be lost behind the +empty placeholder. Selection/reuse generations cancel obsolete scheduled +renders without introducing mirror state. + +The diffable collection snapshot is a topology artifact. It changes only when +section/property membership or order changes (including selection replacement); +same-identity property content never calls `reconfigureItems`, reloads a row, +or reloads the collection. When a positional backend ID is reused by a +replacement property without changing row count, the coordinator detects only +the child object-identity change and rebinds visible cells in place; it does not +apply a collection snapshot. Selection replacement follows the same rule. +Section header content uses its own in-place render path and does not make +property rows topology-dependent. + +`CSSStyles` serializes refresh and mutation operations for that resource. A +toggle submitted during a refresh waits with only its own +`isMutationPending == true`; after the preceding operation finishes it rebuilds +its style-text intent from the latest property/style state. This prevents lost +updates without globally disabling rows or moving a mutation queue into UIKit. + +The migration deletes `PropertyRenderContent`, property-content snapshot diffs, +and preview-only replacement property copies. It does not add a UI mirror +model, a compatibility value wrapper, or a second CSS source of truth. + +```swift +public struct DOMMutationFailure: Error, Hashable, Sendable { + public let nodeID: DOMNode.ID + public let message: String +} + +public struct DOMMutationOutcome { + public let requestedNodeIDs: [DOMNode.ID] + public let appliedNodeIDs: [DOMNode.ID] + public let failures: [DOMMutationFailure] + public let undo: DOMUndoCapability? +} + +public final class DOMUndoCapability { + public nonisolated(nonsending) func undo() async throws + public nonisolated(nonsending) func redo() async throws +} + +public final class RuntimeObjectGroup { + public nonisolated(nonsending) func evaluate( + _ expression: String, + in context: RuntimeContext? = nil + ) async throws -> RuntimeEvaluation + + public nonisolated(nonsending) func properties( + of object: RuntimeObject, + ownProperties: Bool = true + ) async throws -> [RuntimeProperty] + + public nonisolated(nonsending) func preview( + of object: RuntimeObject + ) async throws -> RuntimeObjectPreview + public nonisolated(nonsending) func close() async throws +} + +public struct WebInspectorRuntimeScopeError: Error { + public let operationError: any Error + public let cleanupError: any Error +} +``` + +### Concrete collection queries + +Network and Console queries use closed semantic enums rather than arbitrary key +paths: + +```swift +public struct NetworkQuery: Sendable, Equatable { + public var search: String? + public var resourceCategories: Set + public var methods: Set + public var sort: NetworkSort + public var section: NetworkSection? + public var offset: Int + public var limit: Int? + + public init( + search: String? = nil, + resourceCategories: Set = [], + methods: Set = [], + sort: NetworkSort = .requestTimeDescending, + section: NetworkSection? = nil, + offset: Int = 0, + limit: Int? = nil + ) +} + +public enum NetworkSort: Sendable, Equatable { + case requestTimeAscending + case requestTimeDescending +} + +public enum NetworkSection: Sendable, Equatable { + case method +} + +public struct ConsoleQuery: Sendable, Equatable { + public var levels: Set + public var sort: ConsoleSort + public var section: ConsoleSection? + public var offset: Int + public var limit: Int? + + public init( + levels: Set = [], + sort: ConsoleSort = .insertionAscending, + section: ConsoleSection? = nil, + offset: Int = 0, + limit: Int? = nil + ) +} + +public enum ConsoleSort: Sendable, Equatable { + case insertionAscending + case insertionDescending +} + +public enum ConsoleSection: Sendable, Equatable { + case level +} + +public struct WebInspectorFetchSection: Identifiable { + public let id: WebInspectorFetchSectionID + public let title: String? + public let items: [Model] +} + +public struct WebInspectorFetchedResultsTransaction< + ItemID: Hashable & Sendable +>: Sendable { + public let oldSnapshot: WebInspectorFetchedResultsSnapshot + public let newSnapshot: WebInspectorFetchedResultsSnapshot + public let isReset: Bool + public let sectionChanges: [WebInspectorFetchedResultsSectionChange] + public let itemChanges: [WebInspectorFetchedResultsItemChange] +} + +public enum WebInspectorFetchedResultsUpdate< + ItemID: Hashable & Sendable +>: Sendable { + case initial( + revision: UInt64, + snapshot: WebInspectorFetchedResultsSnapshot + ) + case transaction( + revision: UInt64, + transaction: WebInspectorFetchedResultsTransaction, + reconfigureItemIDs: Set + ) +} + +@Observable +public final class WebInspectorFetchedResults +where Model.ID: Hashable & Sendable { + public var items: [Model] { get } + public var sections: [WebInspectorFetchSection] { get } + public var snapshot: WebInspectorFetchedResultsSnapshot { get } + public var revision: UInt64 { get } + public subscript(id id: Model.ID) -> Model? { get } + + public func updates() + -> AsyncStream> +} + +public extension WebInspectorFetchedResults where Model == NetworkRequest { + var query: NetworkQuery { get } + nonisolated(nonsending) func update(_ query: NetworkQuery) async throws +} + +public extension WebInspectorFetchedResults where Model == ConsoleMessage { + var query: ConsoleQuery { get } + nonisolated(nonsending) func update(_ query: ConsoleQuery) async throws +} +``` + +Empty Network category/method sets and an empty Console level set mean “all.” +Query initializers normalize an empty or whitespace-only Network search to +`nil`, and fail fast for negative offsets or limits. Unsupported predicates, +sort keys, and sections are unrepresentable. +`WebInspectorFetchedResults` stores items, sections, +snapshot, revision, and its concrete query in one private state value and +replaces that value once per publication. Its public properties are computed +projections, so Observation cannot expose a mixed revision/snapshot state. +`WebInspectorFetchSection` and `WebInspectorFetchedResultsTransaction` change +from the deleted `WebInspectorFetchableModel` constraint to `Identifiable` and +`ItemID` constraints respectively; their existing section/item delta vocabulary +is retained. + +`updates()` synchronously registers on the owning actor and enqueues the current +`.initial` value before returning. Subsequent transactions are part of that same +subscription, closing the current public `snapshot`-then-`transactions` gap. +The stream uses newest-one buffering. Every transaction carries both old and +new full identity snapshots plus its delta and revision, so a consumer that +misses an intermediate revision replaces its local diffable snapshot with the +new full snapshot; a contiguous consumer applies the delta. Before first +consumption, coalescing advances the pending `.initial` to the newest complete +snapshot while preserving it as the stream's first element. Thus slow consumers +converge without unbounded backlog or silent stale state. + +Identity objects remain stable. A property-only mutation emits updated item IDs +when rendering must refresh; a topology-affecting mutation also emits the +section/item delta. The per-subscriber broker unions `reconfigureItemIDs` from +every dropped update into the newest retained update. Consequently a slow +diffable consumer reconfigures every identity whose property update it skipped, +even when the full topology snapshot itself did not change. The internal stores +retain live results only weakly, so discarding results unregisters them without +keeping the model context alive. +The zero-state `WebInspectorFetchedResultsController` forwarding wrapper is +deleted; the snapshot, transaction, and result types are retained. + +Concretely, each subscriber owns one mutex-protected pending update. A +publication resumes a waiting consumer directly; otherwise it atomically +replaces the pending update with the newest revision/snapshot and unions the +still-present IDs from both reconfiguration sets. The resulting revision gap +tells the consumer to use the full newest snapshot, while the union preserves +all applicable cell reconfiguration work. Delivery never relies on a second +`AsyncStream.Continuation.yield` racing to replace an already-visible element. + +The existing `NetworkRequestIndex` actor remains the off-owner query/diff owner +and is improved rather than replaced. It consumes compact Sendable record +changes and owns each registered query's filter, membership, order, section, +window, generation, and last mutation sequence. `NetworkRequestStore` owns model +identity resolution and weak result registration; neither the Store nor the +caller actor evaluates a full model graph to create or replace a query. + +Store mutations allocate one scalar sequence and submit exactly one replace or +upsert operation. The index drains only the next contiguous +sequence, so cross-actor scheduling cannot create a checkpoint hole. Result +creation first records an initializing `(registrationID, generation)` in the +Store and passes the current minimum mutation sequence to the index. After the +index drains through that sequence, it installs the registration and computes +its initial compact projection in the same actor turn. Later projections that +reach the Store before result creation resumes coalesce to the newest complete +generation/sequence. The Store installs the initial projection plus that +pending state into one `WebInspectorFetchedResults` state value, adds the weak +live registration, and only then returns the result. + +Query replacement uses an index-owned two-phase candidate. The old active query +continues receiving mutations while the candidate scans. Cancellation or +supersession before candidate commit discards only the candidate. The Store +commits only its newest requested generation; once the index commits that +generation, the Store completes one atomic result publication even if the +calling task is subsequently cancelled. Mutations between index commit and +owner publication coalesce into that generation's pending complete projection. +Stale generations never publish and no retry-until-stable loop or later +mutation is required for convergence. + +Each result owns an immutable registration lifetime token. The Store retains the +result weakly and the index retains the token weakly, so dropping the result +immediately makes the registration inactive without starting an unstructured +cleanup task. Every structured index entry prunes inactive registrations before +query work. Network clear and Console clear/reset are asynchronous boundaries: +they publish an index source-epoch reset and all resulting empty/replacement +states before returning or completing feed application. + +Lifecycle transitions split that reset into a synchronous semantic prepare and +an asynchronous index finish. `start` clears the prior Network attachment, +detach clears all attachment-backed state, and a committed-page transition +clears DOM, Runtime, and Console state in the transition's actor turn. Legacy +and concrete results become empty in that same turn; only the compact index +source-epoch reset is awaited afterward. A destroyed current-page route +intentionally keeps its last DOM snapshot visible during the bootstrap grace +period, but clears Runtime and +Console stream state immediately. Once a replacement is obtained, DOM resets +before retarget enablement and the already-cleared streams are not cleared a +second time, so early replacement events cannot be erased. + +The facade's result-creation methods are `async` so their first filtered/sorted +snapshot is complete before return without doing that scan on the owner actor. +Console receives the same internal index boundary. Newest-one delivery caps +each result's queued topology state at one self-contained snapshot. Benchmarks +and operation-count tests at 10,000 records guard Network and Console initial +query, live insert/update, query replacement, and a stalled subscriber. + +Migration sequencing keeps `WebInspectorFetchDescriptor`, its Context overloads, +and the legacy predicate/key-path planners only while repository consumers move +to these concrete APIs. They remain compatibility build paths in the first query +core and UI commits and are deleted together in the third query commit. During +that compatibility window, a concrete-query result's inherited +`fetchDescriptor` is an inert empty descriptor, not a second query source of +truth. Descriptor-backed results accept only `updateFetchDescriptor`, while +results created by `networkRequests(matching:)` or `consoleMessages(matching:)` +accept only their domain `update(_:)`; both mismatches fail before either owner +mutates. The third query commit removes the descriptor factories, descriptor +update path, inherited descriptor/section properties, mutable request builder, +and legacy planners once repository and Contract consumers no longer reference +any of them. Nonoptional constrained `query` properties shown above are added in +that same deletion commit, when every surviving result has a concrete query +origin. + +### Runtime object lifetime + +`Runtime.evaluate` in ProxyKit gains an object-group parameter. DataKit +evaluations returning remote handles require a `RuntimeObjectGroup`. The group +has explicit `close() async throws`, and `withObjectGroup` always awaits release +on scope exit. Wire names are unique and include the model attachment and page +generation; a group and every object it returns retain that binding internally. +After target replacement, operations throw `staleModel`, while local close +invalidates the group without sending release to the new target (the old target +has already destroyed its objects). + +This explicit lifetime applies only to groups DataKit creates. DataKit never +adopts or releases WebKit's internal `"console"` group. When +`Console.messagesCleared` arrives, `ConsoleMessageStore` invalidates the local +Console-originated `RuntimeObject` ownership and clears its results; it sends no +`Runtime.releaseObjectGroup` because WebKit performed that release before the +event. Releasing again would give two layers authority over one remote resource. + +Cleanup errors are never hidden. If the body succeeds and release fails, the +release error is thrown. If the body fails and release succeeds, the original +error is rethrown. If both fail, `WebInspectorRuntimeScopeError` carries both, +with the operation error as primary. No deinitializer launches a task to release +a remote object. + +### Observation and UI flow + +Semantic identity state is caller-confined `@Observable`; ordered collection +topology uses the retained DOM/fetched-results snapshot-plus-delta contracts. +UIKit creates its context on MainActor, observes identities through +ObservationBridge, and consumes topology updates into diffable/native snapshots. +A custom actor can observe its own context on that actor, but it must explicitly +cancel and release any stored observation token: an isolated callback's metadata +retains the actor even when its closure captures `self` weakly. Sendable update +streams are preferred when publishing out of that actor. Controllers may own +selection presentation, row expansion, scroll position, and render caches, but +may not mirror DOM nodes, Network requests, connection state, or model readiness. + +WebInspectorUI keeps a distinct presentation owner: + +```swift +@MainActor +@Observable +public final class WebInspectorSession { + public let model: WebInspectorModelContext + public private(set) var pageUserInterfaceStyle: UIUserInterfaceStyle + + public init( + tabs: [WebInspectorTab] = [.dom, .network], + additionalDomains: Set = [] + ) + + public func attach(to webView: WKWebView) async throws + public func detach() async + public func close() async +} + +public struct WebInspectorTab: Identifiable { + public let requiredDomains: Set + + public init( + id: ID, + title: String, + systemImage: String, + requiredDomains: Set = [], + makeViewController: @escaping @MainActor (WebInspectorSession) + async throws -> UIViewController + ) +} +``` + +The UI session owns tab/interface selection and `pageUserInterfaceStyle` +observation. Its model configuration is the union of `additionalDomains` and +every tab's declared requirements: built-in DOM requires DOM + CSS, built-in +Network requires Network, and a custom Console tab declares Console. It +creates a fresh Proxy for the web view, passes it to `model.attach(to:)`, and +does not copy model lifecycle or domain state. + +`WebInspectorViewController(session:)` and convenience `init(tabs:)` remain the +public UIKit entry points. Content controllers are retained by tab/session +identity, not page generation. They observe domain epoch/reset updates, replace +only stale semantic resources, and reset only the presentation state whose +lifetime is domain-bound; process replacement does not gratuitously discard +scroll/layout state or recreate a custom tab controller. + +Root/custom controller cache identity is independent of semantic resource +generation. An attachment or page epoch is never a reason to evict every cached +controller. Root teardown (and, if tabs later become mutable, explicit +descriptor removal) owns controller eviction. Public DataKit results publish an +epoch reset through their existing identity. Separately, a model/context epoch +may advance an affected root-owned presentation-resource generation; existing +built-in resource hosts render that owner's replacement state in place. + +The root `WebInspectorViewController`, not `WebInspectorSession`, owns the +content-controller cache and retirement. A custom controller may strongly retain +the session it receives without forming +`session -> cache -> controller -> session`; the session has no edge back to the +root or cache. Root presentation teardown releases/retires controllers, awaits +picker stop and highlight hide, and optionally awaits model detach. Cleanup +failure is logged at that UI lifecycle boundary rather than swallowed. +Page-style observation is invalidated synchronously by the UI session's isolated +deinitializer. + +The built-in Network tab cannot synchronously create its first concrete query: +`NetworkPanelModel.make(context:)` awaits the atomic initial `NetworkQuery` +snapshot and has no empty/default initializer. The root +`PresentationContentStore` therefore owns one Network resource state machine: +`idle`, `loading`, `ready`, or `failed`, together with its context epoch, +generation, task, and revision. Synchronous UIKit selection/layout returns a +native `UIContentUnavailableConfiguration.loading()` container; the same +container replaces loading with ready content or a native failure configuration +in place, so no placeholder model or empty result flashes first. + +Resource state and the ready model are shared across compact/regular requests, +but every `UITab` provider receives a fresh container view controller because +UIKit owns that controller identity. A host/layout switch neither closes nor +recreates the resource. A Network semantic-resource generation transition +cancels and awaits the old load/model without clearing the root/custom +controller cache; root `clear()` retires both resources and controllers. A next +resource generation waits for retirement, and a late factory completion is +retired before it can publish. +`InterfaceModel.tabs` is immutable, so there is no runtime tab-removal owner in +this design. If tabs become mutable, ContentKey eviction and awaited resource +retirement must be introduced together instead of treating view disappearance +as resource close. + +The Network panel owns its current concrete query. Search/filter mutations form +one latest-wins cancel-and-await chain before calling +`WebInspectorFetchedResults.update(_:)`; clear is a committed operation ordered +in the same chain, and a later query waits for it. Explicit model retirement +cancels and awaits that work. The resource task weakly references the root and +the model's `isolated deinit` synchronously cancels remaining work as a backstop; +explicit root retirement remains the awaited correctness path. + +Custom tabs become a real consumer story: + +```swift +let tab = WebInspectorTab( + id: "console", + title: "Console", + systemImage: "terminal", + requiredDomains: [.console] +) { session in + let messages = try await session.model.consoleMessages() + return ConsoleViewController(messages: messages) +} +``` + +The tab receives the UIKit session and reaches canonical DataKit results +through `model`; it does not navigate through a new Console model wrapper. +Reattachment preserves session, root/custom controller, and public +fetched-results owner identities. The result receives its new epoch and reset +snapshot through the existing update contract, so a custom controller needs no +separate replacement signal. The built-in Network presentation owner remains a +distinct case: when its semantic-resource generation changes, it retires the +old panel model and loads the replacement into its existing resource hosts +without evicting the controller cache. + +### DataKit consumer migration + +```swift +// Before: mainContext starts asynchronously after this property returns. +let container = try await WebInspectorContainer(attachingTo: webView) +let context = container.mainContext +let requests = context.network.fetchedResults(for: descriptor) + +// After on MainActor: the convenience initializer returns a ready context. +let context = try await WebInspectorModelContext( + attachingTo: webView, + configuration: .init(domains: [.network]) +) +let requests = try await context.networkRequests( + matching: NetworkQuery(sort: .requestTimeDescending) +) + +for await update in requests.updates() { + apply(update) // initial full snapshot, then self-contained deltas +} + +// The same context API can instead belong to a custom actor. +actor NetworkAnalyzer { + private let context = WebInspectorModelContext( + configuration: .init(domains: [.network]) + ) + + func attach(to proxy: WebInspectorProxy) async throws { + try await context.attach(to: proxy) + } + + func currentSnapshot() async throws + -> WebInspectorFetchedResultsSnapshot + { + try await context.networkRequests().snapshot + } +} + +let proxy = try await WebInspectorProxy(attachingTo: webView) // MainActor +let analyzer = NetworkAnalyzer() +try await analyzer.attach(to: proxy) +``` + +### DataKit testing scenarios + +`WebInspectorDataKitTesting` owns only the model-level scenario that a DataKit +consumer needs. It composes `WebInspectorProxyTestRuntime`; it does not inject +model records, sequence numbers, generations, snapshots, or semantic store +state. + +```swift +let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.dom, .network]), + document: .init(children: [ + .element(id: "button", name: "button") + ]), + networkReplay: [ + .init(id: "request-1", url: "https://example.test/") + ] + ) +) + +let selected = try await runtime.selectElementWithPicker(nodeID: "button") +try await runtime.replacePage(with: .init()) +await runtime.close() +``` + +The scenario driver replies to the configured domain bootstrap, emits replay +before the matching enable reply, and fails unknown commands immediately. Raw +wire tests remain in ProxyKitTesting; DataKitTesting is the ready-model consumer +contract. The runtime and model inherit the actor supplied to `start`, while +fixture values are Sendable. + +## Isolation and Deterministic Teardown + +The platform floor is iOS 18.4 and macOS 15.4 in the root package, the native +bridge package, ContractTests, and applicable Xcode deployment settings. Those +are the Swift runtime availability boundaries for non-MainActor isolated +deinitialization. + +`isolated deinit` is used where static isolation owns synchronous state: + +- `ConnectionCore` (a non-MainActor actor): cancel stored task handles, finish + external synchronized mailboxes/promises and bounded brokers, and assert + synchronous terminal state. +- Other non-MainActor actor resources introduced by the final implementation: + cancel actor-owned task handles and synchronously finish only external + synchronized primitives whose completion does not require another actor hop. +- `WebInspectorTestPeer`: finish its external command mailbox and receiver + synchronously; `WebInspectorProxyTestRuntime.close()` remains the primary + awaited connection teardown. +- `@MainActor NativeAttachment`: synchronously detach the bridge if supported + by the native primitive and release its inspectability token. +- The per-web-view `@MainActor InspectabilityCoordinator`: keep the original + value and membership; only the final token release restores it exactly once. + +The caller-confined `WebInspectorModelContext` and its identity/resource classes +are ordinary non-Sendable classes, not actors or global-actor classes. Swift +6.3 therefore does not permit `isolated deinit` on them, regardless of the +deployment target. Their correctness path is explicit `detach()`/`close()`; +ordinary `deinit` may only cancel a Sendable task handle and finish an external +synchronized primitive that does not retain the context. It cannot drain a +continuation owned by a suspended instance method whose frame retains the +context. The higher platform floor enables isolated deinitialization for the +actual non-MainActor lifecycle actors instead of forcing the entire observable +graph onto MainActor. + +An async actor method frame may retain its actor for the entire suspension. A +continuation stored in that actor therefore cannot be made safe by claiming the +actor's isolated deinitializer will eventually drain it: the suspended frame is +itself allowed to keep deinitialization unreachable. Network/Console query-index +waiters must be cancellation-aware and are drained by explicit close or their +normal terminal owner. The same rule applies to reply waiting. The final +migration replaces the current actor-based `ReplyPromise`—whose suspended +`value()` frame can retain that actor—with an external synchronized terminal +primitive. Its wait does not suspend an actor-isolated method on actor-owned +continuation state; pending-reply close, failure, cancellation, or reply +fulfills it exactly once. `ConnectionCore`'s isolated deinitializer may +synchronously finish that external primitive only as a backstop. + +It does not perform: + +- asynchronous connection close; +- protocol domain disable; +- remote Runtime object/group release; +- work intended to break an existing retain cycle; +- `Task { await self.close() }` or any escaping use of a deinitializing `self`. + +An isolated deinitializer may be enqueued after the executor's current work, so +explicit close remains the correctness path. Tests cover both explicit teardown +and the synchronous fallback. The redesign removes the strongly stored +`any Actor`, repeated `requireOwner`, and per-method isolation plumbing. One +defaulted isolated parameter remains at context attachment, and one private +delivery hop applies feed records on that actor. + +Every stored long-running task follows the same acyclic rule: owner -> task is +allowed only when the task captures the owner weakly and does not retain it +across the next suspension. A weak capture followed by one long-lived +`guard let self` is not sufficient. Backend callbacks also capture their +receiver weakly. Explicit close cancels and awaits tasks and drains +cancellation-aware waiters; dropping all public handles can therefore reach the +isolated deinitializer even when close was omitted. No deinitializer is expected +to break a cycle or terminate an async frame that retains its actor. + +## Access Control + +| Surface | Access | Reason | +| --- | --- | --- | +| Proxy, logical page, concrete struct domain handles/DTOs, structured event scopes | `public` | direct ProxyKit consumer story; package-only protocols share known-domain mechanics | +| Physical target records, target registry, routing keys, capability registry | `package` or `internal` | one core owns routing; no external producer story | +| DataKit model context, identity models, existing tree/query/resource types, concrete queries | `public` | caller-confined custom UI/headless story without facade proliferation | +| Domain stores, mutation/event application, generation replacement, protocol adapters | `package` or `internal` | preserve one writer per model; stores are not public navigation API | +| UIKit composition controllers | `package` | built-in implementation detail | +| Inspector root controller and tab descriptor | `public` | app integration story | +| Raw-wire `WebInspectorTestPeer`, `WebInspectorTestJSONObject`, and explicit `WebInspectorProxyTestRuntime` | `public` in ProxyKitTesting | direct test consumer drives the production core and owns close completion | +| Ready model scenarios | `public` in DataKitTesting | DataKit consumer contract | + +Public setters are limited to value-type configuration and query values. Model +state uses `public private(set)`. No `open` declarations are introduced. + +`WebInspectorTestPeer` exposes raw outbound command correlation and exact-once +reply/failure, raw root/target events, target lifecycle wire helpers, and +connection close/failure. It never accepts decoded semantic events, transport +sequence numbers, model generations, document epochs, or replay markers; the +production core derives all of those. `WebInspectorProxyTestRuntime` owns the +proxy/peer/page tuple and provides explicit async close. Queueing replies, +deferred gates, typed fixture encoding, and command assertions belong to the +package test-support driver and are not product API. + +## Deletions and Consolidations + +The migration removes rather than deprecates the following surfaces: + +- `WebInspectorProxy.currentPage`, `waitForCurrentPage()`, `canReload`, and the + duplicate proxy-level `reload()`. +- Public `WebInspectorTarget` as a logical-current-page facade, synthetic + `.currentPage` routing, and `pageBindingID` duplication. +- Domain `enable()`, `disable()`, cold `events`, public EventStream wrappers, and + DataKit's subscriber barrier/domain enablement registry. +- `WebInspectorContainer`, the strongly owner-retaining `WebInspectorContext`, + per-domain EventPumps, dynamic `requireOwner`, and public/model + `@unchecked Sendable` ownership claims. They are replaced by one + caller-confined `WebInspectorModelContext` and one weak delivery bridge. +- Stateless computed `DOMModelController`, `NetworkModelController`, + `ConsoleModelController`, `RuntimeModelController`, `CSSModelController`, + `PageModelController`, and forwarding `WebInspectorEditHistory` wrapper, + all removed in this slice. +- Duplicate direct Context/model convenience operations after their + `WebInspectorModelContext` facade operation or scoped-resource equivalent + exists. +- `WebInspectorFetchableModel`, generic `WebInspectorFetchDescriptor`, + arbitrary key-path sort/section descriptors, + `WebInspectorFetchRequest`, `WebInspectorMutationOptions`, + `WebInspectorStaleModelPolicy`, and + `WebInspectorFetchedResultsController`. +- The FRC-owned, separately-created transaction stream. Snapshot and delta + updates remain, but one atomic `WebInspectorFetchedResults.updates()` + registration owns their ordering and bounded buffering. +- The UI session's `WebInspectorContainer`/detached-context storage and mirrored + attachment generation; it retains one MainActor-owned DataKit model context + plus genuine presentation state. +- Fire-and-forget CSS mutations and hidden partial-delete errors. +- DataKit's Console-clear `Runtime.releaseObjectGroup` dispatch and any + Console-to-Runtime capability dependency; WebKit owns its `"console"` group. +- Unbounded intermediate Transport -> LiveBackend -> Proxy stream layers where + the core broker can route the decoded value directly. + +Protocol DTOs that have a direct typed-command story are not deleted merely +because the built-in UI does not currently call them. Contract tests will cover +the public command surface selected for retention. + +## Rejected Shapes + +- **Weakening only `WebInspectorTarget.proxy`:** fixes one edge of one cycle and + gives retained targets surprising invalidation semantics. +- **Always consuming the lifecycle stream in Proxy:** refreshes the cache but + preserves two target sources of truth. +- **A public subscription barrier:** exports the hidden ordering invariant + instead of owning it. +- **Caching enable replay for every late subscriber:** makes ProxyKit a second + semantic state store and cannot reconstruct all protocol domains correctly. +- **Keeping domain leases in DataKit:** direct ProxyKit consumers would still + disable each other's domains. +- **Compatibility adapters around Context/controllers:** keep duplicate + semantics and extend the migration indefinitely. +- **An observable wrapper around Context:** adds mirror state without changing + the state owner. +- **Public per-domain `WebInspector*Model` or `WebInspector*Store` facades:** + duplicate the context navigation surface and do not improve large-collection + query cost; internal stores exist only as write owners. +- **Moving UIKit `WebInspectorSession` wholesale into DataKit:** leaks tabs, + page style, and root-owned content loading/retirement into the model layer. +- **One independent DataKit consumer task per domain:** cannot order a page reset + ahead of every new-binding event and multiplies task lifetime cycles. +- **Reading a snapshot and then creating an independent cold transaction + stream:** recreates an atomic registration race at the query/UI boundary. A + single results-owned registration emits current snapshot plus later deltas. +- **A MainActor-only DataKit graph:** makes UIKit convenient but needlessly + prevents headless parsing, indexing, and inspection state from belonging to a + custom actor. +- **Sharing one mutable Context across MainActor and a custom actor:** violates + the SwiftData-like confinement contract. Separate contexts or immutable + Sendable snapshots/deltas cross that boundary. +- **Strongly storing `any Actor` and checking every method:** recreates the + current owner cycle and runtime-only API. Attachment captures the actor once; + public graph confinement is enforced by non-Sendability and Swift's region + isolation. +- **Making every model generic over an owner actor:** propagates the generic + through the full identity graph, still cannot bind a specific actor instance, + and still cannot give an ordinary class an `isolated deinit`. +- **Requiring the consumer to await a permanent `drive(feed:)` scope:** is the + fully checked alternative to the weak bridge, but makes attachment never + return and exports model-feed lifetime composition to every caller. One + audited private bridge is chosen to preserve an attach-then-use API. +- **Async cleanup launched from deinit:** cannot guarantee completion and risks + escaping deinitializing `self`. +- **Unbounded protocol/model-feed buffers or silent drop:** these streams have + no general resynchronization contract. Fetched results are distinct: their + bounded newest update is explicitly self-contained and can resynchronize by + replacing the diffable snapshot. +- **Transport-wide backpressure from a slow subscriber:** can block command + replies and target lifecycle for unrelated consumers. +- **Fallback to a stale target/model:** creates a second source of truth and can + mutate the wrong page. + +## Characterization and Acceptance Tests + +Tests are added before each owner is replaced. Required cases: + +1. An enable handler emits an event before its reply and the structured scope + receives it exactly once. +2. A late second scope starts with the current reset marker and future events, + does not claim historical replay, and still shares one enable lease. +3. Two scopes send one enable; ending one sends no disable; ending the final one + sends one disable. +4. Cancellation while enabling waits for enable and balances it with disable + without leaking a waiter; final-disable failure follows the documented + body/cleanup error precedence. +5. Target destroy/commit updates routing with no external lifecycle consumer. +6. Reset, provisional-message drain, enable replay, domain-complete markers, + and `synchronizationComplete` preserve transport ordering even when an + old-generation consumer is slow. An empty-domain feed still receives the + binding-level completion record. +7. A pending reply for the committing provisional target retargets correctly; + unrelated old-target replies fail rather than moving generations. +8. A stable logical page sends subsequent commands to the new physical binding + and active capabilities re-enable there. +9. An old scoped DTO identifier passed to a current command fails locally and is + never encoded for the new target. +10. Old-generation model identities become stale before new replay is applied, + and attach/retarget synchronization returns only after every configured + replay/bootstrap-complete record and the following binding-level + `synchronizationComplete` are applied. +11. DOM attachment and retarget return with a root snapshot ready; a + `DOM.documentUpdated` racing `getDocument` discards the stale reply, retries, + and orders the accepted snapshot before later DOM deltas. A frame-target + `documentUpdated` that is filtered from the public current-page scope still + emits `domDocumentInvalidated`; DataKit invalidates only that target's + DOM/CSS authority, ignores pre-bootstrap deltas, and reauthorizes it only + from the matching snapshot. +12. A capacity-N public event stream receiving N+1 pending events throws + overflow for that subscriber while a peer continues. Separately, the + authoritative model feed applies enable replay larger than 256 records in + exact sequence before publishing attachment readiness. +13. A malformed known event and malformed root envelope terminate with protocol + violation; an unknown method remains a raw event. +14. Native fatal callbacks reach the connection terminal cause. +15. Explicit close terminates pending replies and streams, detaches the native + bridge, restores inspectability, resumes waiters, and is idempotent. +16. Two attachments to one web view keep it inspectable after the first closes + and restore its original value only after the final close. +17. After close, weak references prove the proxy/core/backend/receiver graph is + deallocated. +18. Dropping connection handles without explicit close reaches isolated deinit; + dropping a caller-confined model context reaches ordinary deinit and cancels + its driver. Weak references prove neither driver retains its context or + owner actor. +19. Isolated-deinit fallbacks synchronously cancel actor-owned task handles and + finish only external synchronized primitives. Native attachment fallback + detaches/restores exactly once. No fallback launches async work or claims to + drain an actor-local continuation whose suspended frame retains the actor. +20. DataKit `attach()` exposes startup failure and does not return before the + configured models and binding-level synchronization record are ready. +21. Same-proxy concurrent attach joins, different-proxy attach supersedes, caller + cancellation cancels only its wait, detach cancels transition state, a + same-proxy attach during retarget joins `.synchronizing`, and a failed + context can retry. +22. Final capability release and late acquire during retarget/enabling reconcile + desired lease count without leaving a stale enable or disabling a live + lease. +23. A Network-only configuration sends no DOM, CSS, Console, Runtime, or + Inspector enable command. A Console-only configuration sends no Runtime + enable command. +24. Same-binding navigation resets DOM/CSS and Runtime as their events require + while retaining Network history; physical replacement resets all domains. + `Console.messagesCleared` empties Console results and makes its local remote + objects stale without sending `Runtime.releaseObjectGroup`. +25. DOM request failures propagate, partial removal exposes applied and failed + nodes plus epoch-bound undo capability, stale operations consistently throw, + and tree consumers retain initial-snapshot-plus-delta behavior. A stalled + tree consumer coalesces to a full snapshot before later deltas. +26. CSS lookup does not change DOM selection and CSS mutations expose backend + failure. +27. Runtime object groups receive unique binding-scoped names, never release on + a replacement target, and expose cleanup failure precedence for success, + body failure, cancellation, and body-plus-cleanup failure. +28. `WebInspectorFetchedResults.updates()` atomically emits current state and + later transactions. A mutation at setup cannot be missed; a stalled + subscriber retains at most one self-contained update and converges by full + snapshot when its revision skips, with dropped property-update IDs folded + into the retained reconfiguration set. +29. Network and Console 10,000-record tests cover initial query, live + insert/update, query replacement, and stalled consumption; live inserts do + not perform whole-model query evaluation on the context's owner actor. + Cancellation plus explicit index close drains sequence/query waiters; actor + deinitialization is not used as waiter completion proof. +30. Discarding Network/Console result objects unregisters their weak live-query + sinks and does not retain the model context. +31. A custom public-only Console tab declares its required domain, obtains + `session.model.consoleMessages()`, follows reattachment, and retains its + controller while result epochs reset. The controller may strongly retain + its UI session; releasing the root cache still deallocates the entire graph. +32. Concurrent requests join one async custom-tab factory; loading, failure, + retry, and root-close cancellation follow the root-owned content state + machine without a root/task cycle. +33. UI presentation retirement awaits picker/highlight cleanup, preserves the + model connection when configured not to detach, and reports cleanup failure. +34. DataKitTesting creates a ready model context, seeds replay, emits picker and + target-replacement events, injects attach failure, and closes + deterministically without raw startup scripting. +35. Existing DOM and Network UIKit behavior, including snapshot-plus-delta + collection topology, reveal intent, selection, editing, and lazy body + loading, remains covered. +36. The same public model API runs on MainActor and on a custom actor; feed + application and Observation callbacks execute on the owning actor. A strict + concurrency compile-fail fixture proves that the context cannot satisfy a + `Sendable` requirement, while its Sendable snapshots and deltas can cross + actors. The delivery bridge preconditions the bound executor at runtime for + deliberate escapes the region checker cannot reject. + +Validation gates: + +- root package test suite on the shared `WebInspectorKit` iOS simulator scheme; +- public-only `ContractTests` package; +- package build/test for macOS where supported; +- Monocly build/test for its applicable schemes; +- DocC build and link validation; +- `git diff --check` and a clean self-review. + +## Measurable Completion Criteria + +- The old `WebInspectorContext`, strongly stored `any Actor`, and + `requireOwner` counts are zero in DataKit. Isolated parameters remain only at + caller-binding/scoped boundaries and the single private feed-application hop. +- Public domain `enable`, `disable`, cold `events`, and subscription barrier + counts are zero. +- DataKit owns no domain enable reference-count registry. +- Physical current-page routing decisions exist only in the connection core; + no proxy/page cache duplicates registry state. +- Model adaptation consumes one mixed-domain ordered feed with reset, + enable-replay, DOM bootstrap, and binding synchronization boundaries; DataKit + has no independent per-domain event pumps. +- Every relevant target document-epoch advance has one ordered + `domDocumentInvalidated` feed boundary before later DOM/CSS deltas; public + event projection does not own model invalidation. +- Every target-scoped public DTO ID carries and validates its opaque generation. +- Known decode failures and root-envelope failures have no `try?`/silent-drop or + `preconditionFailure` path. +- Default ProxyKit protocol event buffers are all bounded and overflow-tested. +- DataKit mixed-feed overflow reports the configured capacity without inventing + a responsible domain. It puts both an in-flight and an attached context in + `.failed`, atomically invalidates authority/resets results, and rejects + operations until explicit re-attachment. +- DataKit contains no `@unchecked Sendable` lifecycle or semantic owner. Its + sole unchecked type is a private weak delivery bridge with no owned model + state; strict-concurrency and deallocation tests cover that boundary. +- Stored connection/model tasks weakly reference both contexts and owner actors; + model-command runners use bounded actor hops plus external synchronized + signals, and deallocation tests prove no owner-task/async-frame cycle. +- Every public DataKit operation has one semantic owner path; the direct/context, + controller, and model convenience triplication is gone. +- Public `WebInspectorDOMModel`, `WebInspectorNetworkModel`, + `WebInspectorConsoleModel`, `WebInspectorRuntimeModel`, `WebInspectorCSSModel`, + and corresponding public `*Store` types do not exist; package stores are sole + write owners, not facade wrappers. +- The public generic fetch trap surface is zero; Network and Console contract + tests compile only supported concrete queries. +- Query results retain their snapshot-plus-delta contract. One results-owned + atomic update registration supplies current state and bounded self-contained + later transactions; live-result registries retain results weakly. +- DOM tree projection remains initial-snapshot-plus-delta based and selection + reveal intent is consumed rather than inferred. +- Network and Console query/index work meets the 10,000-record and stalled + subscriber gates without unbounded queued snapshots or owner-actor full-model + evaluation on live insert. +- DOM/CSS, Runtime, Network, and Console stale validation uses their documented + domain epochs rather than one context-wide generation. +- Console capability acquisition has no Runtime dependency. A Console clear + invalidates Console-originated remote objects locally and never duplicates + WebKit's `"console"` group release on the wire. +- One per-web-view inspectability coordinator, not individual attachments, owns + original-value restoration. +- `WebInspectorSession` owns no controller cache; root-owned custom content may + strongly retain the session and still deallocates when the root is released. +- Attachment/page epochs never evict the root/custom controller cache. Public + results reset in place, while affected root-owned presentation-resource + generations retire and replace their internal content independently. +- The two documented external stories compile and execute using only public + imports. +- No core source file combines connection routing with model state, or more than + one DataKit domain's mutable semantic state. +- The 58 UIKit `canImport(UIKit)` gates are tracked but not counted as a failure + of this core migration because no AppKit UI is in scope. + +## Commit and Integration Plan + +Commits are kept buildable or intentionally test-red only when the commit is a +clearly labeled characterization test immediately followed by its owner change. +The stack begins with the already-approved platform prerequisite, followed by +the design gate and implementation: + +1. `build!: require iOS 18.4 and macOS 15.4` +2. `docs(architecture): define inspector kit ownership` +3. `test(proxy): characterize connection and replay invariants` +4. `refactor(proxy)!: centralize connection and target ownership` +5. `refactor(proxy)!: make domain subscriptions atomic and bounded` +6. `test(data): characterize context and model contracts` +7. `refactor(data)!: replace dynamic context with caller confinement` +8. `refactor(data)!: move DOM and node-bound CSS state to one owner` +9. `refactor(data)!: move Network and Console state to domain owners` +10. `refactor(data)!: scope Runtime resources and concrete queries` +11. `feat(testing): add high-level DataKit scenarios` +12. `refactor(ui)!: expose the model context from the UIKit session` +13. `docs!: publish the new consumer and migration contracts` + +Implementation work may split these further when a domain has an independently +reviewable invariant. It must not combine unrelated cleanup merely to reduce the +commit count. Worker commits are reviewed and integrated locally; pushing and a +pull request are separate explicit actions. + +## Design Gate Approval + +Implementation proceeded after explicit approval of this document. Approval +accepted the breaking removals, the SwiftData-like caller-confined DataKit +context/identity graph, retained snapshot-plus-delta tree and fetched-results +contracts, no new public per-domain Model/Store facades, the split between +DataKit's `WebInspectorModelContext` and UIKit's presentation-only +`WebInspectorSession`, structured ProxyKit event scopes, and the explicit-close +plus correctly scoped isolated-deinit lifecycle contract. diff --git a/Monocly/Monocly.xcodeproj/project.pbxproj b/Monocly/Monocly.xcodeproj/project.pbxproj index 1c44db6a..1d3758a6 100644 --- a/Monocly/Monocly.xcodeproj/project.pbxproj +++ b/Monocly/Monocly.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.1; @@ -449,7 +449,7 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.1; @@ -485,7 +485,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 58KPFKMJJW; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; MACOSX_DEPLOYMENT_TARGET = 26.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = lynnpd.MonoclyTests; @@ -514,7 +514,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 58KPFKMJJW; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; MACOSX_DEPLOYMENT_TARGET = 26.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = lynnpd.MonoclyTests; diff --git a/Monocly/Monocly/Controllers/BrowserInspectorSessionAttachmentLifecycle.swift b/Monocly/Monocly/Controllers/BrowserInspectorSessionAttachmentLifecycle.swift index 4626aedf..44f63426 100644 --- a/Monocly/Monocly/Controllers/BrowserInspectorSessionAttachmentLifecycle.swift +++ b/Monocly/Monocly/Controllers/BrowserInspectorSessionAttachmentLifecycle.swift @@ -5,8 +5,8 @@ import WebInspectorKit @MainActor final class BrowserInspectorSessionAttachmentLifecycle { - typealias AttachAction = @MainActor (WebInspectorSession, WKWebView) async throws -> Void - typealias DetachAction = @MainActor (WebInspectorSession) async -> Void + typealias AttachAction = @MainActor (WKWebView) async throws -> Void + typealias DetachAction = @MainActor () async -> Void enum Attachment { case attached @@ -65,31 +65,54 @@ final class BrowserInspectorSessionAttachmentLifecycle { } private let browserWindow: BrowserWindow + // The lifecycle owns the session lease. Effect tasks capture only the + // pre-bound operations, never this property or the lifecycle itself. private let inspectorSession: WebInspectorSession private let attachAction: AttachAction private let detachAction: DetachAction private var phase: AttachmentPhase = .detached private var lifecycleTask: Task? + private var activeEffectID: UInt64? + private var nextEffectID: UInt64 = 0 private weak var attachedWebView: WKWebView? var onAttachForTesting: ((WKWebView) -> Void)? init( browserWindow: BrowserWindow, inspectorSession: WebInspectorSession, - attachAction: @escaping AttachAction = { inspectorSession, webView in - try await inspectorSession.attach(to: webView) - }, - detachAction: @escaping DetachAction = { inspectorSession in - await inspectorSession.detach() - } + attachAction: AttachAction? = nil, + detachAction: DetachAction? = nil ) { self.browserWindow = browserWindow self.inspectorSession = inspectorSession - self.attachAction = attachAction - self.detachAction = detachAction + self.attachAction = attachAction ?? { [weak inspectorSession] webView in + // The async Session method owns its own call lifetime. Keeping this + // capture weak prevents the stored effect task from extending the + // lifecycle's Session lease before or after that call. + guard let inspectorSession else { + throw CancellationError() + } + try await inspectorSession.attach(to: webView) + } + self.detachAction = detachAction ?? { [weak inspectorSession] in + guard let inspectorSession else { + return + } + await inspectorSession.detach() + } } + isolated deinit { + cancel() + } + + /// Abandons the lifecycle during terminal owner teardown. func cancel() { + phase = .finalized + attachedWebView = nil + activeEffectID = nil + let lifecycleTask = lifecycleTask + self.lifecycleTask = nil lifecycleTask?.cancel() } @@ -189,36 +212,57 @@ final class BrowserInspectorSessionAttachmentLifecycle { } private func startLifecycleTaskIfNeeded() { - guard lifecycleTask == nil else { + guard lifecycleTask == nil, + let effect = phase.currentEffect else { return } - let inspectorSession = inspectorSession + if case let .attach(webView) = effect { + onAttachForTesting?(webView) + } + + let effectID = nextEffectID + nextEffectID &+= 1 + activeEffectID = effectID let attachAction = attachAction let detachAction = detachAction - lifecycleTask = Task { [weak self, inspectorSession, attachAction, detachAction] in - guard let self else { - return - } - while let effect = self.phase.currentEffect { - let result: EffectResult - switch effect { - case let .attach(webView): - do { - self.onAttachForTesting?(webView) - try await attachAction(inspectorSession, webView) - result = .succeeded - } catch { - result = .failed - } - case .detach: - await detachAction(inspectorSession) - result = .succeeded - } - self.finish(effect, result: result) + lifecycleTask = Task { @MainActor [weak self, effect, effectID, attachAction, detachAction] in + let result = await Self.perform( + effect, + attachAction: attachAction, + detachAction: detachAction + ) + self?.commit(effect, result: result, id: effectID) + } + } + + private static func perform( + _ effect: Effect, + attachAction: AttachAction, + detachAction: DetachAction + ) async -> EffectResult { + switch effect { + case let .attach(webView): + do { + try await attachAction(webView) + return .succeeded + } catch { + return .failed } - self.lifecycleTask = nil + case .detach: + await detachAction() + return .succeeded + } + } + + private func commit(_ effect: Effect, result: EffectResult, id: UInt64) { + guard activeEffectID == id else { + return } + activeEffectID = nil + lifecycleTask = nil + finish(effect, result: result) + startLifecycleTaskIfNeeded() } private func finish(_ effect: Effect, result: EffectResult) { diff --git a/Monocly/MonoclyTests/BrowserSessionRestoreTests.swift b/Monocly/MonoclyTests/BrowserSessionRestoreTests.swift index 75d70a60..e3aeefab 100644 --- a/Monocly/MonoclyTests/BrowserSessionRestoreTests.swift +++ b/Monocly/MonoclyTests/BrowserSessionRestoreTests.swift @@ -1104,6 +1104,82 @@ struct BrowserSessionRestoreTests { #expect(actions.detachCount == 0) } + @Test + func inFlightAttachmentDoesNotRetainLifecycleOwner() async throws { + let fixture = try makeAttachmentLifecycleFixture() + let actions = ControlledInspectorAttachmentActions() + var lifecycle: BrowserInspectorSessionAttachmentLifecycle? = BrowserInspectorSessionAttachmentLifecycle( + browserWindow: fixture.browserWindow, + inspectorSession: WebInspectorSession(), + attachAction: actions.attach, + detachAction: actions.detach + ) + weak var retainedLifecycle = lifecycle + + lifecycle?.request(.attached) + await actions.waitUntilAttachStarted(count: 1) + + lifecycle = nil + + #expect(retainedLifecycle == nil) + + actions.releaseAttach() + await actions.waitUntilAttachCompleted(count: 1) + } + + @Test + func lateAttachmentCompletionDoesNotStartPendingReattachmentAfterLifecycleRelease() async throws { + let fixture = try makeAttachmentLifecycleFixture() + let actions = ControlledInspectorAttachmentActions() + var lifecycle: BrowserInspectorSessionAttachmentLifecycle? = BrowserInspectorSessionAttachmentLifecycle( + browserWindow: fixture.browserWindow, + inspectorSession: WebInspectorSession(), + attachAction: actions.attach, + detachAction: actions.detach + ) + weak var retainedLifecycle = lifecycle + + lifecycle?.request(.attached) + await actions.waitUntilAttachStarted(count: 1) + fixture.browserWindow.selectTab(id: fixture.secondTabID) + lifecycle?.selectedWebViewDidChange(to: fixture.secondWebView) + + lifecycle = nil + #expect(retainedLifecycle == nil) + + actions.releaseAttach() + await actions.waitUntilAttachCompleted(count: 1) + + #expect(actions.attachedWebViews == [fixture.firstWebView]) + #expect(actions.detachCount == 0) + } + + @Test + func cancellingInFlightAttachmentRejectsLateCompletionAndPendingReattachment() async throws { + let fixture = try makeAttachmentLifecycleFixture() + let actions = ControlledInspectorAttachmentActions() + let lifecycle = BrowserInspectorSessionAttachmentLifecycle( + browserWindow: fixture.browserWindow, + inspectorSession: WebInspectorSession(), + attachAction: actions.attach, + detachAction: actions.detach + ) + + lifecycle.request(.attached) + await actions.waitUntilAttachStarted(count: 1) + fixture.browserWindow.selectTab(id: fixture.secondTabID) + lifecycle.selectedWebViewDidChange(to: fixture.secondWebView) + + lifecycle.cancel() + actions.releaseAttach() + await actions.waitUntilAttachCompleted(count: 1) + lifecycle.request(.attached) + + #expect(actions.attachedWebViews == [fixture.firstWebView]) + #expect(actions.attachCompletionCount == 1) + #expect(actions.detachCount == 0) + } + @Test func mainSceneDelegateConnectsWithRestoredBrowserStore() throws { try withTemporarySessionStore { sessionStore, _ in @@ -1360,24 +1436,28 @@ struct BrowserSessionRestoreTests { @MainActor private final class ControlledInspectorAttachmentActions { private(set) var attachedWebViews: [WKWebView] = [] + private(set) var attachCompletionCount = 0 private(set) var detachCount = 0 private var attachContinuation: CheckedContinuation? private var attachResult: Result = .success(()) private var attachStartedWaiters: [(Int, CheckedContinuation)] = [] + private var attachCompletedWaiters: [(Int, CheckedContinuation)] = [] private var detachWaiters: [(Int, CheckedContinuation)] = [] - func attach(_ inspectorSession: WebInspectorSession, _ webView: WKWebView) async throws { + func attach(_ webView: WKWebView) async throws { attachedWebViews.append(webView) resumeAttachStartedWaiters() await withCheckedContinuation { continuation in attachContinuation = continuation } + attachCompletionCount += 1 + resumeAttachCompletedWaiters() let result = attachResult attachResult = .success(()) try result.get() } - func detach(_ inspectorSession: WebInspectorSession) async { + func detach() async { detachCount += 1 resumeDetachWaiters() } @@ -1391,6 +1471,15 @@ struct BrowserSessionRestoreTests { } } + func waitUntilAttachCompleted(count: Int) async { + guard attachCompletionCount < count else { + return + } + await withCheckedContinuation { continuation in + attachCompletedWaiters.append((count, continuation)) + } + } + func waitUntilDetachCount(_ count: Int) async { guard detachCount < count else { return @@ -1415,6 +1504,14 @@ struct BrowserSessionRestoreTests { } } + private func resumeAttachCompletedWaiters() { + let readyWaiters = attachCompletedWaiters.filter { attachCompletionCount >= $0.0 } + attachCompletedWaiters.removeAll { attachCompletionCount >= $0.0 } + for waiter in readyWaiters { + waiter.1.resume() + } + } + private func resumeDetachWaiters() { let readyWaiters = detachWaiters.filter { detachCount >= $0.0 } detachWaiters.removeAll { detachCount >= $0.0 } diff --git a/Package.swift b/Package.swift index c214a0a0..2fcf7490 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ let package = Package( name: "WebInspectorKit", defaultLocalization: "en", platforms: [ - .iOS(.v18), .macOS(.v15) + .iOS("18.4"), .macOS("15.4") ], products: [ .library( @@ -28,6 +28,10 @@ let package = Package( name: "WebInspectorDataKit", targets: ["WebInspectorDataKit"] ), + .library( + name: "WebInspectorDataKitTesting", + targets: ["WebInspectorDataKitTesting"] + ), .library( name: "WebInspectorKit", targets: ["WebInspectorKit"] @@ -77,6 +81,15 @@ let package = Package( ], swiftSettings: strictSwiftSettings ), + .target( + name: "WebInspectorDataKitTesting", + dependencies: [ + "WebInspectorDataKit", + "WebInspectorProxyKit", + "WebInspectorProxyKitTesting", + ], + swiftSettings: strictSwiftSettings + ), .target( name: "WebInspectorNativeBridge", dependencies: [ @@ -156,14 +169,16 @@ let package = Package( .target( name: "WebInspectorKit", dependencies: [ - "WebInspectorUI" + "WebInspectorUI", + "WebInspectorDataKit" ], swiftSettings: strictSwiftSettings ), .target( name: "WebInspectorTestSupport", dependencies: [ - "WebInspectorProxyKit" + "WebInspectorProxyKit", + "WebInspectorProxyKitTesting", ], path: "Tests/WebInspectorTestSupport", swiftSettings: strictSwiftSettings @@ -182,6 +197,7 @@ let package = Package( name: "WebInspectorDataKitTests", dependencies: [ "WebInspectorDataKit", + "WebInspectorDataKitTesting", "WebInspectorProxyKitTesting", "WebInspectorProxyKit", "WebInspectorTestSupport" diff --git a/Packages/WebInspectorNativeBridge/Package.swift b/Packages/WebInspectorNativeBridge/Package.swift index cef81af9..82ee5cd3 100644 --- a/Packages/WebInspectorNativeBridge/Package.swift +++ b/Packages/WebInspectorNativeBridge/Package.swift @@ -12,7 +12,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "WebInspectorNativeBridge", platforms: [ - .iOS(.v18), .macOS(.v15) + .iOS("18.4"), .macOS("15.4") ], products: [ .library( diff --git a/README.md b/README.md index ca059294..268f52b5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ UIKit Web Inspector for `WKWebView`. ## Requirements - Swift 6.3+ -- iOS 18+ +- iOS 18.4+ for the built-in UIKit inspector +- iOS 18.4+ or macOS 15.4+ for ProxyKit, DataKit, and the native bridge - ARM64/ARM64e Apple runtime; Intel Mac / x86_64 simulator environments are not supported. @@ -31,8 +32,9 @@ UIKit Web Inspector for `WKWebView`. | --- | --- | | `WebInspectorKit` | You want the built-in UIKit inspector UI. | | `WebInspectorDataKit` | You want observable DOM, Network, Console, Runtime, and CSS models for a custom UI. | +| `WebInspectorDataKitTesting` | You want a ready DataKit model scenario with replay, picker, replacement, and failure controls. | | `WebInspectorProxyKit` | You want typed Web Inspector protocol commands and events directly over an inspected `WKWebView`. | -| `WebInspectorProxyKitTesting` | You want a public test runtime for ProxyKit/DataKit consumers without the native WebKit bridge. | +| `WebInspectorProxyKitTesting` | You want to drive ProxyKit's production connection path from a concrete raw WebKit peer in tests. | ## Quick Start @@ -70,15 +72,17 @@ let inspector = WebInspectorViewController( ``` The built-in tab surface exposes DOM and Network tabs. Apps can also add UIKit -tabs with a `UIViewController` factory: +tabs with an asynchronous `UIViewController` factory: ```swift let consoleTab = WebInspectorTab( id: "app_console", title: "Console", - systemImage: "terminal" + systemImage: "terminal", + requiredDomains: [.console] ) { session in - ConsoleViewController(inspectorSession: session) + let messages = try await session.model.consoleMessages() + return ConsoleViewController(messages: messages) } let inspector = WebInspectorViewController( @@ -86,6 +90,62 @@ let inspector = WebInspectorViewController( ) ``` +## Testing the Raw Wire + +`WebInspectorProxyKitTesting` attaches a concrete ``WebInspectorTestPeer`` +below ProxyKit's real connection core. Commands still pass through the +production target registry, router, JSON codecs, model feed, and authority +checks. Tests receive raw commands in transport FIFO order and must complete +each command exactly once: + +```swift +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +let runtime = try await WebInspectorProxyTestRuntime.start() + +let reload = Task { + try await runtime.page.page.reload() +} + +let command = try await runtime.peer.commands.next() +precondition(command.method == "Page.reload") +try await runtime.peer.reply(to: command) +try await reload.value + +await runtime.close() +``` + +Use `WebInspectorTestJSONObject` for raw `params` and result objects, and use +the peer's target lifecycle and event methods when a test needs inbound WebKit +traffic. The testing product does not provide a semantic backend or synthetic +model-state injection path. + +## Testing DataKit Models + +`WebInspectorDataKitTesting` composes the raw peer into model-level scenarios. +It answers only the protocol bootstrap owned by the scenario; replay and target +replacement still traverse ProxyKit's production connection core: + +```swift +import WebInspectorDataKitTesting + +let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + document: .init(children: [ + .element(id: "button", name: "button") + ]), + networkReplay: [ + .init(id: "initial-request", url: "https://example.test/") + ] + ) +) + +let selected = try await runtime.selectElementWithPicker(nodeID: "button") +precondition(selected.localName == "button") +await runtime.close() +``` + ## Documentation The DocC workflow publishes [package documentation](https://lynnswap.github.io/WebInspectorKit/documentation/) @@ -103,8 +163,9 @@ to GitHub Pages. Sources/ WebInspectorKit/ Public built-in inspector product. WebInspectorDataKit/ Observable inspector model product. + WebInspectorDataKitTesting/ Ready production-path DataKit test scenarios. WebInspectorProxyKit/ Typed protocol proxy product. - WebInspectorProxyKitTesting/ Test runtime for proxy/model consumers. + WebInspectorProxyKitTesting/ Production-path raw peer test runtime. WebInspectorUI*/ Internal UIKit implementation targets. Packages/ WebInspectorNativeBridge/ Local native bridge package for ProxyKit internals. diff --git a/Scripts/preview-docc.py b/Scripts/preview-docc.py index 3ab47290..9e6ae00d 100755 --- a/Scripts/preview-docc.py +++ b/Scripts/preview-docc.py @@ -19,7 +19,7 @@ BASE_PATH = "WebInspectorKit" HOST = "127.0.0.1" -SIMULATOR_TRIPLE = "arm64-apple-ios18.0-simulator" +SIMULATOR_TRIPLE = "arm64-apple-ios18.4-simulator" TARGETS = [ "WebInspectorUI", "WebInspectorDataKit", diff --git a/Scripts/verify-model-context-confinement.sh b/Scripts/verify-model-context-confinement.sh new file mode 100755 index 00000000..75d5bcc3 --- /dev/null +++ b/Scripts/verify-model-context-confinement.sh @@ -0,0 +1,39 @@ +#!/bin/zsh + +set -euo pipefail + +repo_root=${0:A:h:h} +build_root="$repo_root/.build/arm64-apple-macosx/debug" +fixture="$repo_root/Tests/ConcurrencyFixtures/ModelContextCannotCrossActors.swift" +diagnostics=$(mktemp -t webinspector-model-context-confinement) +trap 'rm -f "$diagnostics"' EXIT + +swift build --package-path "$repo_root" --target WebInspectorDataKit >/dev/null + +if xcrun swiftc \ + -typecheck \ + -swift-version 6 \ + -strict-concurrency=complete \ + -target arm64-apple-macosx15.4 \ + -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ + -I "$build_root/Modules" \ + -module-cache-path "$build_root/ModuleCache" \ + -Xcc -fmodule-map-file="$build_root/MachOKitC.build/module.modulemap" \ + -Xcc -I \ + -Xcc "$repo_root/.build/checkouts/MachOKit/Sources/MachOKitC/include" \ + -Xcc -fmodule-map-file="$build_root/WebInspectorNativeBridgeObjC.build/module.modulemap" \ + -Xcc -I \ + -Xcc "$repo_root/Packages/WebInspectorNativeBridge/Sources/WebInspectorNativeBridgeObjC/include" \ + "$fixture" >"$diagnostics" 2>&1 +then + print -u2 "Expected WebInspectorModelContext Sendable misuse to fail type checking." + exit 1 +fi + +if ! /usr/bin/grep -q "WebInspectorModelContext.*does not conform to the 'Sendable' protocol" "$diagnostics"; then + /bin/cat "$diagnostics" >&2 + print -u2 "The confinement fixture failed for an unexpected reason." + exit 1 +fi + +print "WebInspectorModelContext confinement fixture passed." diff --git a/Sources/WebInspectorDataKit/CSSStyleModels.swift b/Sources/WebInspectorDataKit/CSSStyleModels.swift index 223e1f3c..0670794e 100644 --- a/Sources/WebInspectorDataKit/CSSStyleModels.swift +++ b/Sources/WebInspectorDataKit/CSSStyleModels.swift @@ -1,7 +1,8 @@ +import Observation import WebInspectorProxyKit /// A display section in the CSS style sidebar for a DOM element. -public struct CSSStyleSection: Sendable, Identifiable { +public struct CSSStyleSection: Identifiable { /// The source of a style section. public enum Kind: Hashable, Sendable { /// The element's inline `style` declaration. @@ -59,21 +60,23 @@ public struct CSSStyleSection: Sendable, Identifiable { title: String?, rule: CSS.Rule?, style: CSS.Style, - isEditable: Bool + isEditable: Bool, + propertyModels: [CSSStyleProperty]? = nil ) { + let styleModel = CSSStyle(style, propertyModels: propertyModels) self.id = id self.kind = kind self.title = title - self.rule = rule.map(CSSStyleRule.init) - self.style = CSSStyle(style) + self.rule = rule.map { CSSStyleRule($0, styleModel: styleModel) } + self.style = styleModel self.isEditable = isEditable proxyRule = rule proxyStyle = style } } -/// A DataKit value model for a CSS declaration block. -public struct CSSStyle: Sendable, Identifiable { +/// A DataKit model for a CSS declaration block. +public struct CSSStyle: Identifiable { /// Stable identity for a CSS declaration block. public struct ID: Hashable, Sendable { /// The raw backend style identifier. @@ -207,10 +210,13 @@ public struct CSSStyle: Sendable, Identifiable { self.isEditable = isEditable } - package init(_ style: CSS.Style) { + package init( + _ style: CSS.Style, + propertyModels: [CSSStyleProperty]? = nil + ) { self.init( id: ID(style.id), - properties: style.properties.map(CSSStyleProperty.init), + properties: propertyModels ?? style.properties.map(CSSStyleProperty.init), shorthandEntries: style.shorthandEntries.map(ShorthandEntry.init), cssText: style.cssText, range: style.range.map(SourceRange.init), @@ -221,8 +227,8 @@ public struct CSSStyle: Sendable, Identifiable { } } -/// A DataKit value model for a matched CSS rule. -public struct CSSStyleRule: Sendable { +/// A DataKit model for a matched CSS rule. +public struct CSSStyleRule { /// Stable identity for an editable CSS rule. public struct ID: Hashable, Sendable { /// The raw backend rule identifier. @@ -337,7 +343,7 @@ public struct CSSStyleRule: Sendable { self.isImplicitlyNested = isImplicitlyNested } - package init(_ rule: CSS.Rule) { + package init(_ rule: CSS.Rule, styleModel: CSSStyle? = nil) { self.init( id: rule.id.map(ID.init), selectors: rule.selectorList.selectors, @@ -347,15 +353,16 @@ public struct CSSStyleRule: Sendable { sourceLine: rule.sourceLine, sourceLocation: rule.sourceLocation.map(CSSStyle.SourceRange.init), origin: Origin(rule.origin), - style: CSSStyle(rule.style), + style: styleModel ?? CSSStyle(rule.style), groupings: rule.groupings.map(Grouping.init), isImplicitlyNested: rule.isImplicitlyNested ) } } -/// A DataKit value model for a CSS property declaration. -public struct CSSStyleProperty: Sendable, Identifiable { +/// An observable CSS property declaration with stable backend identity. +@Observable +public final class CSSStyleProperty: Identifiable { /// Stable identity for an editable CSS property. public struct ID: Hashable, Sendable { /// The raw backend property identifier. @@ -418,34 +425,37 @@ public struct CSSStyleProperty: Sendable, Identifiable { public let id: ID /// The property name. - public let name: String + public private(set) var name: String /// The property value. - public let value: String + public private(set) var value: String /// The declaration priority, such as `important`. - public let priority: String? + public private(set) var priority: String? /// The original declaration text, if WebKit reported it. - public let text: String? + public private(set) var text: String? /// A Boolean value indicating whether WebKit parsed the declaration successfully. - public let parsedOk: Bool + public private(set) var parsedOk: Bool /// The cascade status of the declaration. - public let status: Status + public private(set) var status: Status /// A Boolean value indicating whether the declaration is implicit. - public let implicit: Bool + public private(set) var implicit: Bool /// Source range for the declaration, if known. - public let range: CSSStyle.SourceRange? + public private(set) var range: CSSStyle.SourceRange? /// A Boolean value indicating whether WebKit accepts edits for this declaration. - public let isEditable: Bool + public private(set) var isEditable: Bool /// A Boolean value indicating whether the declaration was changed through DataKit. - public let isModifiedByInspector: Bool + public private(set) var isModifiedByInspector: Bool + + /// A Boolean value indicating whether this declaration has a submitted mutation awaiting completion. + public private(set) var isMutationPending: Bool /// A Boolean value indicating whether the declaration is enabled. public var isEnabled: Bool { @@ -482,9 +492,10 @@ public struct CSSStyleProperty: Sendable, Identifiable { self.range = range self.isEditable = isEditable self.isModifiedByInspector = isModifiedByInspector + isMutationPending = false } - package init(_ property: CSS.Property) { + package convenience init(_ property: CSS.Property) { self.init( id: ID(property.id), name: property.name, @@ -499,6 +510,33 @@ public struct CSSStyleProperty: Sendable, Identifiable { isModifiedByInspector: property.isModifiedByInspector ) } + + package func update(from property: CSSStyleProperty) { + if name != property.name { name = property.name } + if value != property.value { value = property.value } + if priority != property.priority { priority = property.priority } + if text != property.text { text = property.text } + if parsedOk != property.parsedOk { parsedOk = property.parsedOk } + if status != property.status { status = property.status } + if implicit != property.implicit { implicit = property.implicit } + if range != property.range { range = property.range } + if isEditable != property.isEditable { isEditable = property.isEditable } + if isModifiedByInspector != property.isModifiedByInspector { + isModifiedByInspector = property.isModifiedByInspector + } + } + + package func beginMutation() -> Bool { + guard isMutationPending == false else { + return false + } + isMutationPending = true + return true + } + + package func endMutation() { + isMutationPending = false + } } /// A computed CSS property for a DOM node. diff --git a/Sources/WebInspectorDataKit/CSSStyles.swift b/Sources/WebInspectorDataKit/CSSStyles.swift index 7feac9e7..b3ac4052 100644 --- a/Sources/WebInspectorDataKit/CSSStyles.swift +++ b/Sources/WebInspectorDataKit/CSSStyles.swift @@ -6,13 +6,15 @@ import WebInspectorProxyKit /// inspector rewrites its declaration. A property is "modified by /// inspector" while its current state differs from this baseline. private struct CSSPropertyInspectorBaseline: Equatable { + var styleID: CSS.Style.ID var name: String var value: String var priority: String? var text: String? var status: CSS.Status - init(_ property: CSS.Property) { + init(styleID: CSS.Style.ID, property: CSS.Property) { + self.styleID = styleID name = property.name value = property.value priority = property.priority @@ -21,6 +23,208 @@ private struct CSSPropertyInspectorBaseline: Equatable { } } +private struct InspectorBaselineName: Hashable { + var styleID: CSS.Style.ID + var propertyName: String +} + +/// Context-owned edit history for backend style declarations. `CSS.Style.ID` +/// already carries the target scope added by ProxyKit, while the current-page +/// scope is retired by document reset. Stylesheet rule IDs are shared by every +/// DOM node matched by that rule, so node-owned `CSSStyles` resources must +/// consult one baseline owner. +final class CSSInspectorBaselineStore { + private var baselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] = [:] + + func reset() { + baselines.removeAll() + } + + func reset(targetID: WebInspectorTarget.ID) { + baselines = baselines.filter { _, baseline in + baseline.styleID.targetScopeRawValue != targetID.rawValue + } + } + + func recordIfNeeded( + propertyID: CSSStyleProperty.ID, + styleID: CSS.Style.ID, + property: CSS.Property + ) { + guard baselines[propertyID] == nil else { + return + } + baselines[propertyID] = CSSPropertyInspectorBaseline( + styleID: styleID, + property: property + ) + } + + func reconcile( + styleIDs: Set, + incomingSections: [CSSStyleSection] + ) { + guard styleIDs.isEmpty == false else { + return + } + + let incomingPropertiesByStyleID = cssPropertiesByStyleID(in: incomingSections) + let baselineNameCounts = Dictionary( + grouping: baselines.values, + by: { baseline in + InspectorBaselineName( + styleID: baseline.styleID, + propertyName: baseline.name + ) + } + ).mapValues(\.count) + var reconciledBaselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] = [:] + + for (propertyID, baseline) in baselines { + guard styleIDs.contains(baseline.styleID) else { + reconciledBaselines[propertyID] = baseline + continue + } + let name = InspectorBaselineName( + styleID: baseline.styleID, + propertyName: baseline.name + ) + guard baselineNameCounts[name] == 1, + let incomingProperties = incomingPropertiesByStyleID[baseline.styleID] else { + continue + } + let matchingProperties = incomingProperties.filter { $0.name == baseline.name } + guard matchingProperties.count == 1, + let incomingProperty = matchingProperties.first else { + continue + } + reconciledBaselines[incomingProperty.id] = baseline + } + baselines = reconciledBaselines + } + + func applyingBaselines( + to style: CSS.Style, + clearsRestoredBaselines: Bool = false + ) -> CSS.Style { + guard baselines.isEmpty == false else { + return style + } + var style = style + style.properties = style.properties.map { property in + let propertyID = CSSStyleProperty.ID(property.id) + guard let baseline = baselines[propertyID], + baseline.name == property.name else { + return property + } + let isModified = CSSPropertyInspectorBaseline( + styleID: style.id, + property: property + ) != baseline + if isModified == false, clearsRestoredBaselines { + baselines[propertyID] = nil + } + return CSS.Property( + id: property.id, + name: property.name, + value: property.value, + priority: property.priority, + text: property.text, + parsedOk: property.parsedOk, + status: property.status, + implicit: property.implicit, + range: property.range, + isEditable: property.isEditable, + isModifiedByInspector: isModified + ) + } + return style + } +} + +private func cssPropertiesByStyleID( + in sections: [CSSStyleSection] +) -> [CSS.Style.ID: [CSSStyleProperty]] { + var result: [CSS.Style.ID: [CSSStyleProperty]] = [:] + for section in sections { + let styleID = section.proxyStyle.id + if let existing = result[styleID] { + precondition( + existing.map(\.name) == section.style.properties.map(\.name), + "Sections sharing a CSS style must agree on declaration topology." + ) + } else { + result[styleID] = section.style.properties + } + } + return result +} + +private actor CSSStylesOperationGate { + private struct Waiter { + var continuation: CheckedContinuation + } + + private var isAcquired = false + private var waiters: [UInt64: Waiter] = [:] + private var waiterOrder: [UInt64] = [] + private var nextWaiterID: UInt64 = 0 + + func acquire() async throws { + try Task.checkCancellation() + guard isAcquired else { + isAcquired = true + return + } + precondition(nextWaiterID < UInt64.max, "CSS operation waiter identity overflowed.") + nextWaiterID += 1 + let waiterID = nextWaiterID + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + waiters[waiterID] = Waiter(continuation: continuation) + waiterOrder.append(waiterID) + } + } onCancel: { + Task { + await self.cancel(waiterID: waiterID) + } + } + do { + try Task.checkCancellation() + } catch { + // A release may win the race with the cancellation handler and + // hand this waiter ownership. Return that ownership before + // surfacing cancellation so the next waiter cannot deadlock. + release() + throw error + } + } + + func release() { + precondition(isAcquired, "CSS operation gate released without an owner.") + while let waiterID = waiterOrder.first { + waiterOrder.removeFirst() + guard let waiter = waiters.removeValue(forKey: waiterID) else { + continue + } + waiter.continuation.resume(returning: ()) + return + } + isAcquired = false + } + + private func cancel(waiterID: UInt64) { + waiters.removeValue(forKey: waiterID)?.continuation.resume( + throwing: CancellationError() + ) + } +} + /// Observable CSS state for one DOM element. @Observable public final class CSSStyles: WebInspectorPersistentModel { @@ -68,15 +272,18 @@ public final class CSSStyles: WebInspectorPersistentModel { /// Computed properties for the element. public private(set) var computedProperties: [CSSComputedProperty] - @ObservationIgnored weak var modelContext: WebInspectorContext? - @ObservationIgnored private var inspectorBaselines: [CSSStyleProperty.ID: CSSPropertyInspectorBaseline] + @ObservationIgnored weak var modelContext: WebInspectorModelContext? + @ObservationIgnored private let inspectorBaselineStore: CSSInspectorBaselineStore + @ObservationIgnored private var hasCompletedLoad: Bool + @ObservationIgnored private let operationGate = CSSStylesOperationGate() - init(nodeID: DOMNode.ID, modelContext: WebInspectorContext) { + init(nodeID: DOMNode.ID, modelContext: WebInspectorModelContext) { id = ID(nodeID: nodeID) phase = .loading sections = [] computedProperties = [] - inspectorBaselines = [:] + inspectorBaselineStore = modelContext.cssInspectorBaselineStore + hasCompletedLoad = false self.modelContext = modelContext } @@ -89,9 +296,12 @@ public final class CSSStyles: WebInspectorPersistentModel { inlineStyles: CSS.InlineStyles, computedProperties: [CSS.ComputedProperty] ) { - sections = CSSStyleSectionBuilder.makeSections(matched: matchedStyles, inline: inlineStyles) - .map { section($0, replacingStyleWith: applyingInspectorBaselines(to: $0.proxyStyle)) } + let rawSections = CSSStyleSectionBuilder.makeSections(matched: matchedStyles, inline: inlineStyles) + let incomingSections = rawSections + .map { section($0, replacingStyleWith: inspectorBaselineStore.applyingBaselines(to: $0.proxyStyle)) } + sections = reconciledSections(incomingSections) self.computedProperties = computedProperties.map(CSSComputedProperty.init) + hasCompletedLoad = true phase = .loaded } @@ -102,24 +312,28 @@ public final class CSSStyles: WebInspectorPersistentModel { func markUnavailable() { sections = [] computedProperties = [] - inspectorBaselines = [:] + hasCompletedLoad = false phase = .unavailable } func fail(_ error: WebInspectorProxyError) { sections = [] computedProperties = [] - inspectorBaselines = [:] + hasCompletedLoad = false phase = .failed(error) } + func cancelLoading() { + phase = hasCompletedLoad ? .needsRefresh : .unavailable + } + /// Synchronous validation for a property toggle: returns the backend /// command inputs when the property is currently editable, or nil to /// refuse the toggle (stale phase, non-editable section/style/property, /// no-op toggle, or unrewritable style text). - func setStyleTextIntent(for propertyID: CSSStyleProperty.ID, enabled: Bool) -> SetStyleTextIntent? { + func setStyleTextIntent(for property: CSSStyleProperty, enabled: Bool) -> SetStyleTextIntent? { guard phase == .loaded, - let (sectionIndex, propertyIndex) = locateProperty(propertyID) else { + let (sectionIndex, propertyIndex) = locateProperty(property) else { return nil } let section = sections[sectionIndex] @@ -130,9 +344,9 @@ public final class CSSStyles: WebInspectorPersistentModel { guard style.isEditable else { return nil } - let property = style.properties[propertyIndex] - guard property.isEditable, - (property.status != .disabled) != enabled, + let proxyProperty = style.properties[propertyIndex] + guard proxyProperty.isEditable, + (proxyProperty.status != .disabled) != enabled, let text = CSSStyleTextRewriter.rewrittenStyleText( style: style, propertyIndex: propertyIndex, @@ -143,9 +357,19 @@ public final class CSSStyles: WebInspectorPersistentModel { return SetStyleTextIntent(styleID: style.id, text: text) } - func setDeclarationTextIntent(for propertyID: CSSStyleProperty.ID, text replacementText: String) -> SetStyleTextIntent? { + func contains(property: CSSStyleProperty) -> Bool { + sections.contains { section in + section.style.properties.contains { $0 === property } + } + } + + func contains(ruleID: CSSStyleRule.ID) -> Bool { + sections.contains { $0.rule?.id == ruleID } + } + + func setDeclarationTextIntent(for property: CSSStyleProperty, text replacementText: String) -> SetStyleTextIntent? { guard phase == .loaded, - let (sectionIndex, propertyIndex) = locateProperty(propertyID) else { + let (sectionIndex, propertyIndex) = locateProperty(property) else { return nil } let section = sections[sectionIndex] @@ -156,8 +380,8 @@ public final class CSSStyles: WebInspectorPersistentModel { guard style.isEditable else { return nil } - let property = style.properties[propertyIndex] - guard property.isEditable, + let proxyProperty = style.properties[propertyIndex] + guard proxyProperty.isEditable, let text = CSSStyleTextRewriter.rewrittenStyleText( style: style, propertyIndex: propertyIndex, @@ -174,31 +398,85 @@ public final class CSSStyles: WebInspectorPersistentModel { /// `isModifiedByInspector` against recorded baselines, and marks the /// styles stale for the follow-up refresh. func applySetStyleText(result: CSS.Style, for propertyID: CSSStyleProperty.ID) { + var updatedSections = sections var didRewriteSection = false + let mutatedProperty = sections.lazy + .flatMap(\.style.properties) + .first { $0.id == propertyID } for index in sections.indices where sections[index].proxyStyle.id == result.id { let section = sections[index] - if inspectorBaselines[propertyID] == nil, - let property = section.proxyStyle.properties.first(where: { $0.id == propertyID.proxyID }) { - inspectorBaselines[propertyID] = CSSPropertyInspectorBaseline(property) + if let property = section.proxyStyle.properties.first(where: { $0.id == propertyID.proxyID }) { + inspectorBaselineStore.recordIfNeeded( + propertyID: propertyID, + styleID: section.proxyStyle.id, + property: property + ) } let normalized = CSSStyleSectionBuilder.normalizedStyle( result, isEditable: section.isEditable, ruleOrigin: section.proxyRule?.origin ) - sections[index] = self.section(section, replacingStyleWith: applyingInspectorBaselines(to: normalized)) + updatedSections[index] = self.section( + section, + replacingStyleWith: normalized + ) didRewriteSection = true } if didRewriteSection { + inspectorBaselineStore.reconcile( + styleIDs: styleIDsWithChangedPropertyTopology(in: updatedSections), + incomingSections: updatedSections + ) + let normalizedSections = updatedSections.map { + section( + $0, + replacingStyleWith: inspectorBaselineStore.applyingBaselines( + to: $0.proxyStyle, + clearsRestoredBaselines: true + ) + ) + } + sections = reconciledSections( + normalizedSections, + preservingMutationOf: mutatedProperty + ) phase = .needsRefresh } } - private func locateProperty(_ propertyID: CSSStyleProperty.ID) -> (sectionIndex: Int, propertyIndex: Int)? { + func withExclusiveOperation( + isolation: isolated (any Actor)? = #isolation, + _ operation: () async throws -> Output + ) async throws -> Output { + _ = isolation + try await operationGate.acquire() + do { + try Task.checkCancellation() + let output = try await operation() + await operationGate.release() + return output + } catch { + await operationGate.release() + recoverLoadingPhaseAfterCancellation(error) + throw error + } + } + + private func recoverLoadingPhaseAfterCancellation(_ error: any Error) { + guard error is CancellationError, phase == .loading else { + return + } + cancelLoading() + } + + private func locateProperty(_ property: CSSStyleProperty) -> (sectionIndex: Int, propertyIndex: Int)? { for sectionIndex in sections.indices { guard let propertyIndex = sections[sectionIndex].proxyStyle.properties.firstIndex( - where: { $0.id == propertyID.proxyID } - ) else { + where: { $0.id == property.id.proxyID } + ), + sections[sectionIndex].style.properties.indices.contains(propertyIndex), + sections[sectionIndex].style.properties[propertyIndex] === property else { continue } return (sectionIndex, propertyIndex) @@ -219,34 +497,98 @@ public final class CSSStyles: WebInspectorPersistentModel { ) } - private func applyingInspectorBaselines(to style: CSS.Style) -> CSS.Style { - guard inspectorBaselines.isEmpty == false else { - return style - } - var style = style - style.properties = style.properties.map { property in - let propertyID = CSSStyleProperty.ID(property.id) - guard let baseline = inspectorBaselines[propertyID] else { - return property + private func reconciledSections( + _ incomingSections: [CSSStyleSection], + preservingMutationOf mutatedProperty: CSSStyleProperty? = nil + ) -> [CSSStyleSection] { + let existingPropertiesByStyleID = propertiesByStyleID(in: sections) + var reconciledPropertiesByStyleID: [CSS.Style.ID: [CSSStyleProperty]] = [:] + return incomingSections.map { section in + let styleID = section.proxyStyle.id + let properties: [CSSStyleProperty] + if let reconciled = reconciledPropertiesByStyleID[styleID] { + properties = reconciled + } else { + properties = reconciledProperties( + existing: existingPropertiesByStyleID[styleID] ?? [], + incoming: section.style.properties, + preservingMutationOf: mutatedProperty + ) + reconciledPropertiesByStyleID[styleID] = properties } - let isModified = CSSPropertyInspectorBaseline(property) != baseline - if isModified == false { - inspectorBaselines[propertyID] = nil - } - return CSS.Property( - id: property.id, - name: property.name, - value: property.value, - priority: property.priority, - text: property.text, - parsedOk: property.parsedOk, - status: property.status, - implicit: property.implicit, - range: property.range, - isEditable: property.isEditable, - isModifiedByInspector: isModified + return CSSStyleSection( + id: section.id, + kind: section.kind, + title: section.title, + rule: section.proxyRule, + style: section.proxyStyle, + isEditable: section.isEditable, + propertyModels: properties ) } - return style } + + private func reconciledProperties( + existing: [CSSStyleProperty], + incoming: [CSSStyleProperty], + preservingMutationOf mutatedProperty: CSSStyleProperty? + ) -> [CSSStyleProperty] { + guard existing.map(\.name) == incoming.map(\.name) else { + // Backend property IDs are positional. Once declaration topology + // changes, no raw ID proves semantic continuity, so every old + // handle must become stale rather than aliasing a new declaration. + return incoming + } + + let nameCounts = Dictionary(grouping: existing, by: \.name).mapValues(\.count) + return zip(existing, incoming).map { existingProperty, incomingProperty in + let canPreserveIdentity = nameCounts[existingProperty.name] == 1 + || existingProperty === mutatedProperty + || hasEqualDeclarationContent(existingProperty, incomingProperty) + guard canPreserveIdentity else { + // Duplicate declarations have no protocol identity beyond + // position. A changed duplicate is ambiguous unless this + // operation explicitly owns that declaration. + return incomingProperty + } + existingProperty.update(from: incomingProperty) + return existingProperty + } + } + + private func styleIDsWithChangedPropertyTopology( + in incomingSections: [CSSStyleSection] + ) -> Set { + let existingPropertiesByStyleID = propertiesByStyleID(in: sections) + let incomingPropertiesByStyleID = propertiesByStyleID(in: incomingSections) + let allStyleIDs = Set(existingPropertiesByStyleID.keys) + .union(incomingPropertiesByStyleID.keys) + return Set(allStyleIDs.filter { styleID in + existingPropertiesByStyleID[styleID]?.map(\.name) + != incomingPropertiesByStyleID[styleID]?.map(\.name) + }) + } + + private func propertiesByStyleID( + in sections: [CSSStyleSection] + ) -> [CSS.Style.ID: [CSSStyleProperty]] { + cssPropertiesByStyleID(in: sections) + } + + private func hasEqualDeclarationContent( + _ lhs: CSSStyleProperty, + _ rhs: CSSStyleProperty + ) -> Bool { + lhs.name == rhs.name + && lhs.value == rhs.value + && lhs.priority == rhs.priority + && lhs.text == rhs.text + && lhs.parsedOk == rhs.parsedOk + && lhs.status == rhs.status + && lhs.implicit == rhs.implicit + && lhs.range == rhs.range + && lhs.isEditable == rhs.isEditable + && lhs.isModifiedByInspector == rhs.isModifiedByInspector + } + } diff --git a/Sources/WebInspectorDataKit/ConsoleMessage.swift b/Sources/WebInspectorDataKit/ConsoleMessage.swift index a9f8470b..a7c4920d 100644 --- a/Sources/WebInspectorDataKit/ConsoleMessage.swift +++ b/Sources/WebInspectorDataKit/ConsoleMessage.swift @@ -4,7 +4,7 @@ import WebInspectorProxyKit /// Observable model for one console message. @Observable -public final class ConsoleMessage: WebInspectorFetchableModel { +public final class ConsoleMessage: WebInspectorPersistentModel { /// Stable identity for a console message within a context. public struct ID: Comparable, Hashable, Sendable { let ordinal: Int @@ -59,14 +59,14 @@ public final class ConsoleMessage: WebInspectorFetchableModel { public private(set) var timestamp: Double? let targetID: WebInspectorTarget.ID? - @ObservationIgnored weak var modelContext: WebInspectorContext? + @ObservationIgnored weak var modelContext: WebInspectorModelContext? init( id: ID, message: Console.Message, parameters: [RuntimeObject], targetID: WebInspectorTarget.ID?, - modelContext: WebInspectorContext + modelContext: WebInspectorModelContext ) { self.id = id source = message.source diff --git a/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift new file mode 100644 index 00000000..d94c5874 --- /dev/null +++ b/Sources/WebInspectorDataKit/ConsoleMessageIndex.swift @@ -0,0 +1,528 @@ +import Foundation + +/// Owns compact Console records and concrete query projections away from the +/// model-context actor. +package actor ConsoleMessageIndex { + package typealias QueryProjection = WebInspectorIndexedQueryProjection + package typealias QueryDelivery = WebInspectorIndexedQueryDelivery + + private enum Mutation { + case replace([ConsoleMessageRecordInput], sourceEpoch: UInt64?) + case upsert(ConsoleMessageRecordInput) + } + + private struct PendingMutation { + var mutation: Mutation + var continuation: CheckedContinuation<[QueryDelivery], Never> + } + + private struct SequenceWaiter { + var minimumSequence: UInt64 + var continuation: CheckedContinuation + } + + private final class WeakLifetime { + weak var value: WebInspectorQueryRegistrationLifetime? + + init(_ value: WebInspectorQueryRegistrationLifetime) { + self.value = value + } + } + + private struct QueryVersion { + var generation: UInt64 + var query: ConsoleQuery + var matchingIDs: [ConsoleMessage.ID] + var snapshot: WebInspectorFetchedResultsSnapshot + var sequence: UInt64 + var acknowledgedSequence: UInt64 + } + + private struct QueryRegistration { + var lifetime: WeakLifetime + var active: QueryVersion + var candidate: QueryVersion? + } + + private var recordsByID: [ConsoleMessage.ID: ConsoleMessageRecord] = [:] + private var orderedIDs: [ConsoleMessage.ID] = [] + private var lastUpdatedSequenceByID: [ConsoleMessage.ID: UInt64] = [:] + private var lastAppliedSequence: UInt64 = 0 + private var sourceEpoch: UInt64 = 0 + private var pendingMutationsBySequence: [UInt64: PendingMutation] = [:] + private var sequenceWaiters: [UInt64: SequenceWaiter] = [:] + private var nextSequenceWaiterID: UInt64 = 0 + private var queryRegistrations: [WebInspectorQueryRegistrationID: QueryRegistration] = [:] + + package init() {} + + @discardableResult + package func replace( + with inputs: [ConsoleMessageRecordInput], + sequence: UInt64, + sourceEpoch: UInt64? = nil + ) async -> [QueryDelivery] { + await enqueue(.replace(inputs, sourceEpoch: sourceEpoch), sequence: sequence) + } + + @discardableResult + package func upsert( + _ input: ConsoleMessageRecordInput, + sequence: UInt64 + ) async -> [QueryDelivery] { + await enqueue(.upsert(input), sequence: sequence) + } + + private func enqueue(_ mutation: Mutation, sequence: UInt64) async -> [QueryDelivery] { + precondition( + sequence > lastAppliedSequence, + "ConsoleMessageIndex received an already-applied mutation sequence." + ) + precondition( + pendingMutationsBySequence[sequence] == nil, + "ConsoleMessageIndex received a duplicate mutation sequence." + ) + return await withCheckedContinuation { continuation in + pendingMutationsBySequence[sequence] = PendingMutation( + mutation: mutation, + continuation: continuation + ) + drainContiguousMutations() + } + } + + private func drainContiguousMutations() { + while lastAppliedSequence < UInt64.max { + let sequence = lastAppliedSequence + 1 + guard let pending = pendingMutationsBySequence.removeValue(forKey: sequence) else { + resumeSequenceWaiters() + return + } + let deliveries = apply(pending.mutation, sequence: sequence) + lastAppliedSequence = sequence + pending.continuation.resume(returning: deliveries) + } + precondition( + pendingMutationsBySequence.isEmpty, + "ConsoleMessageIndex mutation sequence overflowed." + ) + resumeSequenceWaiters() + } + + private func apply(_ mutation: Mutation, sequence: UInt64) -> [QueryDelivery] { + switch mutation { + case let .replace(inputs, replacementSourceEpoch): + if let replacementSourceEpoch { + precondition( + replacementSourceEpoch >= sourceEpoch, + "ConsoleMessageIndex source epochs must not move backwards." + ) + sourceEpoch = replacementSourceEpoch + } + recordsByID = [:] + recordsByID.reserveCapacity(inputs.count) + orderedIDs = [] + orderedIDs.reserveCapacity(inputs.count) + lastUpdatedSequenceByID = [:] + lastUpdatedSequenceByID.reserveCapacity(inputs.count) + for input in inputs { + upsertRecord(input, sequence: sequence) + } + return updateAllQueryRegistrations(sequence: sequence, rebuilding: true) + case let .upsert(input): + upsertRecord(input, sequence: sequence) + return updateAllQueryRegistrations( + sequence: sequence, + rebuilding: false, + changedID: input.id + ) + } + } + + private func upsertRecord(_ input: ConsoleMessageRecordInput, sequence: UInt64) { + let isNewRecord = recordsByID[input.id] == nil + let record = ConsoleMessageRecord(input: input) + recordsByID[record.id] = record + lastUpdatedSequenceByID[record.id] = sequence + if isNewRecord { + orderedIDs.append(record.id) + } + } + + package func register( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: ConsoleQuery, + lifetime: WebInspectorQueryRegistrationLifetime, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + try await waitUntilApplied(minimumSequence) + try Task.checkCancellation() + pruneQueryRegistrations() + guard lifetime.isCurrent(generation: generation) else { + throw CancellationError() + } + precondition( + queryRegistrations[id] == nil, + "ConsoleMessageIndex received a duplicate query registration ID." + ) + let version = makeQueryVersion( + generation: generation, + query: query, + acknowledgedSequence: lastAppliedSequence + ) + try Task.checkCancellation() + guard lifetime.isCurrent(generation: generation) else { + throw CancellationError() + } + queryRegistrations[id] = QueryRegistration( + lifetime: WeakLifetime(lifetime), + active: version, + candidate: nil + ) + return projection(for: version) + } + + package func prepareReplacement( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: ConsoleQuery, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + try await waitUntilApplied(minimumSequence) + try Task.checkCancellation() + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.lifetime.value?.isCurrent(generation: generation) == true, + generation > registration.active.generation, + generation > (registration.candidate?.generation ?? 0) else { + throw CancellationError() + } + let candidate = makeQueryVersion( + generation: generation, + query: query, + acknowledgedSequence: lastAppliedSequence + ) + try Task.checkCancellation() + guard registration.lifetime.value?.isCurrent(generation: generation) == true else { + throw CancellationError() + } + registration.candidate = candidate + queryRegistrations[id] = registration + return projection(for: candidate) + } + + package func commitReplacement( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) -> QueryProjection? { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.lifetime.value?.isCurrent(generation: generation) == true, + var candidate = registration.candidate, + candidate.generation == generation else { + return nil + } + candidate.acknowledgedSequence = candidate.sequence + registration.active = candidate + registration.candidate = nil + queryRegistrations[id] = registration + return projection(for: candidate) + } + + package func discardCandidate( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.candidate?.generation == generation else { + return + } + registration.candidate = nil + queryRegistrations[id] = registration + } + + package func discardCandidates( + id: WebInspectorQueryRegistrationID, + through generation: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + let candidate = registration.candidate, + candidate.generation <= generation else { + return + } + registration.candidate = nil + queryRegistrations[id] = registration + } + + package func acknowledge( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + sourceEpoch: UInt64, + sequence: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.active.generation == generation, + self.sourceEpoch == sourceEpoch else { + return + } + registration.active.acknowledgedSequence = max( + registration.active.acknowledgedSequence, + sequence + ) + queryRegistrations[id] = registration + } + + private func waitUntilApplied(_ minimumSequence: UInt64) async throws { + try Task.checkCancellation() + guard lastAppliedSequence < minimumSequence else { + return + } + precondition( + nextSequenceWaiterID < UInt64.max, + "ConsoleMessageIndex sequence waiter identity overflowed." + ) + let waiterID = nextSequenceWaiterID + nextSequenceWaiterID += 1 + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + sequenceWaiters[waiterID] = SequenceWaiter( + minimumSequence: minimumSequence, + continuation: continuation + ) + if Task.isCancelled { + cancelSequenceWaiter(id: waiterID) + } else { + resumeSequenceWaiters() + } + } + } onCancel: { + Task { + await self.cancelSequenceWaiter(id: waiterID) + } + } + } + + private func cancelSequenceWaiter(id: UInt64) { + sequenceWaiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) + } + + private func resumeSequenceWaiters() { + let readyIDs = sequenceWaiters.compactMap { id, waiter in + waiter.minimumSequence <= lastAppliedSequence ? id : nil + } + for id in readyIDs { + sequenceWaiters.removeValue(forKey: id)?.continuation.resume(returning: ()) + } + } + + private func updateAllQueryRegistrations( + sequence: UInt64, + rebuilding: Bool, + changedID: ConsoleMessage.ID? = nil + ) -> [QueryDelivery] { + pruneQueryRegistrations() + var deliveries: [QueryDelivery] = [] + deliveries.reserveCapacity(queryRegistrations.count * 2) + for id in Array(queryRegistrations.keys) { + guard var registration = queryRegistrations[id] else { + continue + } + update( + ®istration.active, + sequence: sequence, + rebuilding: rebuilding, + changedID: changedID + ) + deliveries.append(QueryDelivery( + registrationID: id, + generation: registration.active.generation, + projection: projection(for: registration.active) + )) + if var candidate = registration.candidate { + update( + &candidate, + sequence: sequence, + rebuilding: rebuilding, + changedID: changedID + ) + registration.candidate = candidate + deliveries.append(QueryDelivery( + registrationID: id, + generation: candidate.generation, + projection: projection(for: candidate) + )) + } + queryRegistrations[id] = registration + } + return deliveries + } + + private func update( + _ version: inout QueryVersion, + sequence: UInt64, + rebuilding: Bool, + changedID: ConsoleMessage.ID? + ) { + if rebuilding { + version.matchingIDs = matchingIDs(for: version.query) + } else if let changedID { + version.matchingIDs.removeAll { $0 == changedID } + if let record = recordsByID[changedID], matches(record, query: version.query) { + insert(changedID, into: &version.matchingIDs, query: version.query) + } + } + version.snapshot = snapshot(matchingIDs: version.matchingIDs, query: version.query) + version.sequence = sequence + } + + private func makeQueryVersion( + generation: UInt64, + query: ConsoleQuery, + acknowledgedSequence: UInt64 + ) -> QueryVersion { + let matchingIDs = matchingIDs(for: query) + return QueryVersion( + generation: generation, + query: query, + matchingIDs: matchingIDs, + snapshot: snapshot(matchingIDs: matchingIDs, query: query), + sequence: lastAppliedSequence, + acknowledgedSequence: acknowledgedSequence + ) + } + + private func projection(for version: QueryVersion) -> QueryProjection { + let reconfigureItemIDs = Set(version.snapshot.itemIDs.filter { id in + lastUpdatedSequenceByID[id, default: 0] > version.acknowledgedSequence + }) + return QueryProjection( + sourceEpoch: sourceEpoch, + sequence: version.sequence, + snapshot: version.snapshot, + reconfigureItemIDs: reconfigureItemIDs + ) + } + + private func pruneQueryRegistrations() { + queryRegistrations = queryRegistrations.filter { _, registration in + registration.lifetime.value != nil + } + } + +#if DEBUG + package func isMutationPendingForTesting(sequence: UInt64) -> Bool { + pendingMutationsBySequence[sequence] != nil + } + + package func isSequenceWaiterPendingForTesting(minimumSequence: UInt64) -> Bool { + sequenceWaiters.values.contains { $0.minimumSequence == minimumSequence } + } + + package func queryRegistrationCountForTesting() -> Int { + pruneQueryRegistrations() + return queryRegistrations.count + } +#endif + + private func matchingIDs(for query: ConsoleQuery) -> [ConsoleMessage.ID] { + var ids = orderedIDs.filter { id in + recordsByID[id].map { matches($0, query: query) } ?? false + } + ids.sort { lhsID, rhsID in + guard let lhs = recordsByID[lhsID], let rhs = recordsByID[rhsID] else { + preconditionFailure("ConsoleMessageIndex lost a matching record while sorting a query.") + } + return ordersBefore(lhs, rhs, query: query) + } + return ids + } + + private func matches(_ record: ConsoleMessageRecord, query: ConsoleQuery) -> Bool { + query.levels.isEmpty || query.levels.contains { level in + level.rawValue == record.levelRawValue + } + } + + private func insert( + _ id: ConsoleMessage.ID, + into ids: inout [ConsoleMessage.ID], + query: ConsoleQuery + ) { + guard let record = recordsByID[id] else { + return + } + var lowerBound = 0 + var upperBound = ids.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + guard let midpointRecord = recordsByID[ids[midpoint]] else { + preconditionFailure("ConsoleMessageIndex lost a matching record during insertion.") + } + if ordersBefore(midpointRecord, record, query: query) { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + ids.insert(id, at: lowerBound) + } + + private func ordersBefore( + _ lhs: ConsoleMessageRecord, + _ rhs: ConsoleMessageRecord, + query: ConsoleQuery + ) -> Bool { + switch query.sort { + case .insertionAscending: + return lhs.orderIndex < rhs.orderIndex + case .insertionDescending: + return lhs.orderIndex > rhs.orderIndex + } + } + + private func snapshot( + matchingIDs: [ConsoleMessage.ID], + query: ConsoleQuery + ) -> WebInspectorFetchedResultsSnapshot { + let lowerBound = min(query.offset, matchingIDs.count) + let upperBound: Int + if let limit = query.limit { + upperBound = min(lowerBound + limit, matchingIDs.count) + } else { + upperBound = matchingIDs.count + } + let visibleIDs = Array(matchingIDs[lowerBound.. IndexResult { + var deliveries: [ConsoleMessageIndex.QueryDelivery] = [] + for action in actions { + switch action { + case let .replace(inputs, sequence, sourceEpoch): + deliveries += await index.replace( + with: inputs, + sequence: sequence, + sourceEpoch: sourceEpoch + ) + case let .upsert(input, sequence): + deliveries += await index.upsert(input, sequence: sequence) + } + } + return IndexResult(deliveries: deliveries) + } + } + + package struct IndexResult: Sendable { + fileprivate let deliveries: [ConsoleMessageIndex.QueryDelivery] + } + + package struct IndexAcknowledgementWork: Sendable { + fileprivate struct Entry: Sendable { + let id: WebInspectorQueryRegistrationID + let generation: UInt64 + let sourceEpoch: UInt64 + let sequence: UInt64 + } + + fileprivate let index: ConsoleMessageIndex + fileprivate let entries: [Entry] + + package nonisolated(nonsending) func run() async { + for entry in entries { + await index.acknowledge( + id: entry.id, + generation: entry.generation, + sourceEpoch: entry.sourceEpoch, + sequence: entry.sequence + ) + } + } + } + + private enum ConcreteQueryBufferDestination { + case candidate + case committing + } + + private struct PendingConcreteQuery { + var generation: UInt64 + var query: ConsoleQuery + var projection: ConsoleMessageIndex.QueryProjection? + } + + private struct ConcreteQueryRegistration { + var results: WeakWebInspectorFetchedResults + var activeGeneration: UInt64 + var activeQuery: ConsoleQuery + var candidate: PendingConcreteQuery? + var committing: [UInt64: PendingConcreteQuery] + } + + package struct Effects { + package var runtimeObjectsToUnregister: [RuntimeObject] = [] + package var clearedAllMessages = false + } + + package struct QueryIndexReset: Sendable { + fileprivate var inputs: [ConsoleMessageRecordInput] + fileprivate var sequence: UInt64 + fileprivate var sourceEpoch: UInt64 + } + + package struct PreparedModelEvent { + package let effects: Effects + package let indexWork: IndexWork? + } + +#if DEBUG + package struct PerformanceCounters: Equatable { + package var fullModelProjectionCount = 0 + package var fullRecordProjectionCount = 0 + package var incrementalRecordProjectionCount = 0 + package var resultIdentityLookupCount = 0 + } +#endif + + private var messagesByID: [ConsoleMessage.ID: ConsoleMessage] + private var orderedMessageIDs: [ConsoleMessage.ID] + private var orderIndicesByID: [ConsoleMessage.ID: Int] + private var lastMessageID: ConsoleMessage.ID? + private var lastMessageIDByTargetID: [WebInspectorTarget.ID: ConsoleMessage.ID] + private var nextMessageOrdinal: Int + private let queryIndex: ConsoleMessageIndex + private var queryIndexSequence: UInt64 + private var queryIndexNeedsRebuild: Bool + private var querySourceEpoch: UInt64 + private var nextConcreteQueryRegistrationID: UInt64 + private var initializingConcreteQueries: [ + WebInspectorQueryRegistrationID: PendingConcreteQuery + ] + private var concreteQueryRegistrations: [ + WebInspectorQueryRegistrationID: ConcreteQueryRegistration + ] +#if DEBUG + private var performanceCounters: PerformanceCounters +#endif + + package init() { + messagesByID = [:] + orderedMessageIDs = [] + orderIndicesByID = [:] + lastMessageID = nil + lastMessageIDByTargetID = [:] + nextMessageOrdinal = 0 + queryIndex = ConsoleMessageIndex() + queryIndexSequence = 0 + queryIndexNeedsRebuild = false + querySourceEpoch = 0 + nextConcreteQueryRegistrationID = 0 + initializingConcreteQueries = [:] + concreteQueryRegistrations = [:] +#if DEBUG + performanceCounters = PerformanceCounters() +#endif + } + +#if DEBUG + package var performanceCountersForTesting: PerformanceCounters { + performanceCounters + } + + package func resetPerformanceCountersForTesting() { + performanceCounters = PerformanceCounters() + } +#endif + + package func message(for id: ConsoleMessage.ID) -> ConsoleMessage? { + messagesByID[id] + } + + package nonisolated(nonsending) func results( + matching query: ConsoleQuery, + modelContext: WebInspectorModelContext + ) async throws -> WebInspectorFetchedResults { + await syncQueryIndexIfNeeded() + let id = allocateConcreteQueryRegistrationID() + let lifetime = WebInspectorQueryRegistrationLifetime() + let results = WebInspectorFetchedResults(modelContext: modelContext) + results.installQueryRegistration(id: id, lifetime: lifetime) + let generation = results.nextConcreteQueryGeneration() + initializingConcreteQueries[id] = PendingConcreteQuery( + generation: generation, + query: query, + projection: nil + ) + + let initialProjection: ConsoleMessageIndex.QueryProjection + do { + initialProjection = try await queryIndex.register( + id: id, + generation: generation, + query: query, + lifetime: lifetime, + minimumSequence: queryIndexSequence + ) + } catch { + initializingConcreteQueries[id] = nil + throw error + } + + guard var initialization = initializingConcreteQueries.removeValue(forKey: id), + initialization.generation == generation else { + preconditionFailure("Console query initialization lost its owner state after index commit.") + } + initialization.projection = coalesce( + initialization.projection, + with: initialProjection + ) + let installedProjection = initialization.projection ?? initialProjection + guard installedProjection.sourceEpoch == querySourceEpoch else { + throw CancellationError() + } + results.installInitialConsoleQuery( + query, + generation: generation, + projection: installedProjection, + lookup: { id in self.messageForResult(id) } + ) + concreteQueryRegistrations[id] = ConcreteQueryRegistration( + results: WeakWebInspectorFetchedResults(results), + activeGeneration: generation, + activeQuery: query, + candidate: nil, + committing: [:] + ) + await queryIndex.acknowledge( + id: id, + generation: generation, + sourceEpoch: installedProjection.sourceEpoch, + sequence: installedProjection.sequence + ) + return results + } + + package nonisolated(nonsending) func update( + _ query: ConsoleQuery, + for results: WebInspectorFetchedResults + ) async throws { + guard let id = results.concreteQueryRegistrationID else { + preconditionFailure("Console fetched results are not registered in this store.") + } + await syncQueryIndexIfNeeded() + guard var registration = concreteQueryRegistrations[id], + registration.results.value === results else { + preconditionFailure("Console fetched results are not registered in this store.") + } + let generation = results.nextConcreteQueryGeneration() + registration.candidate = PendingConcreteQuery( + generation: generation, + query: query, + projection: nil + ) + concreteQueryRegistrations[id] = registration + + do { + let prepared = try await queryIndex.prepareReplacement( + id: id, + generation: generation, + query: query, + minimumSequence: queryIndexSequence + ) + buffer( + prepared, + for: id, + generation: generation, + query: query, + destination: .candidate + ) + guard results.isCurrentConcreteQueryGeneration(generation) else { + throw CancellationError() + } + try Task.checkCancellation() + } catch { + await queryIndex.discardCandidates(id: id, through: generation) + clearCandidate(id: id, generation: generation) + throw error + } + + guard var beforeCommit = concreteQueryRegistrations[id], + let candidate = beforeCommit.candidate, + candidate.generation == generation, + candidate.projection?.sourceEpoch == querySourceEpoch else { + await queryIndex.discardCandidates(id: id, through: generation) + clearCandidate(id: id, generation: generation) + throw CancellationError() + } + beforeCommit.committing[generation] = candidate + beforeCommit.candidate = nil + concreteQueryRegistrations[id] = beforeCommit + + guard let committed = await queryIndex.commitReplacement( + id: id, + generation: generation + ) else { + clearCommitting(id: id, generation: generation) + throw CancellationError() + } + + guard var afterCommit = concreteQueryRegistrations[id], + var pendingPublication = afterCommit.committing.removeValue(forKey: generation) else { + preconditionFailure("Console query replacement lost committed publication state.") + } + pendingPublication.projection = coalesce( + pendingPublication.projection, + with: committed + ) + let publication = pendingPublication.projection ?? committed + let applied = results.applyConsoleQueryProjection( + publication, + query: query, + generation: generation, + isReplacement: true, + lookup: { id in self.messageForResult(id) } + ) + if generation > afterCommit.activeGeneration { + afterCommit.activeGeneration = generation + afterCommit.activeQuery = query + } + concreteQueryRegistrations[id] = afterCommit + if applied { + await queryIndex.acknowledge( + id: id, + generation: generation, + sourceEpoch: publication.sourceEpoch, + sequence: publication.sequence + ) + } + } + + package func indexWork(for reset: QueryIndexReset) -> IndexWork { + IndexWork( + index: queryIndex, + actions: [ + .replace( + inputs: reset.inputs, + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + ] + ) + } + + package func prepareModelEvent( + _ event: Console.Event, + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorModelContext, + registerRuntimeObject: (Runtime.RemoteObject) -> RuntimeObject + ) -> PreparedModelEvent { + switch event { + case let .messageAdded(payload): + precondition(nextMessageOrdinal < Int.max, "ConsoleMessage identity ordinal overflowed.") + let id = ConsoleMessage.ID(nextMessageOrdinal) + nextMessageOrdinal += 1 + let message = ConsoleMessage( + id: id, + message: payload, + parameters: payload.parameters.map(registerRuntimeObject), + targetID: targetID, + modelContext: modelContext + ) + messagesByID[id] = message + orderIndicesByID[id] = orderedMessageIDs.count + orderedMessageIDs.append(id) + lastMessageID = id + if let targetID { + lastMessageIDByTargetID[targetID] = id + } + return PreparedModelEvent( + effects: Effects(), + indexWork: upsertWork(for: message) + ) + case let .messageRepeatCountUpdated(count, timestamp): + let candidateID = targetID.flatMap { lastMessageIDByTargetID[$0] } ?? lastMessageID + guard let candidateID, let message = messagesByID[candidateID] else { + skipEvent("Console.messageRepeatCountUpdated arrived before any tracked message") + return PreparedModelEvent(effects: Effects(), indexWork: nil) + } + message.updateRepeatCount(count, timestamp: timestamp) + return PreparedModelEvent( + effects: Effects(), + indexWork: upsertWork(for: message) + ) + case .messagesCleared: + let removedMessages = removeMessages(targetID: targetID) + let objects = targetID == nil + ? [] + : unreferencedRuntimeObjects(from: removedMessages) + let reset = targetID == nil + ? prepareEmptyQueryIndexReset() + : prepareQueryIndexReset() + return PreparedModelEvent( + effects: Effects( + runtimeObjectsToUnregister: objects, + clearedAllMessages: targetID == nil + ), + indexWork: indexWork(for: reset) + ) + case .unknown: + return PreparedModelEvent(effects: Effects(), indexWork: nil) + } + } + + package func commit(_ result: IndexResult) -> IndexAcknowledgementWork? { + let entries = applyConcreteDeliveriesSynchronously(result.deliveries) + guard !entries.isEmpty else { + return nil + } + return IndexAcknowledgementWork(index: queryIndex, entries: entries) + } + + private func upsertWork(for message: ConsoleMessage) -> IndexWork { + var actions: [IndexWork.Action] = [] + if queryIndexNeedsRebuild { + queryIndexNeedsRebuild = false + actions.append(.replace( + inputs: currentRecordInputs(), + sequence: nextQueryIndexSequence(), + sourceEpoch: querySourceEpoch + )) + } + actions.append(.upsert( + input: recordInput(for: message), + sequence: nextQueryIndexSequence() + )) + return IndexWork(index: queryIndex, actions: actions) + } + + package nonisolated(nonsending) func apply( + _ event: Console.Event, + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorModelContext, + registerRuntimeObject: (Runtime.RemoteObject) -> RuntimeObject + ) async -> Effects { + switch event { + case let .messageAdded(payload): + let parameters = payload.parameters.map(registerRuntimeObject) + await insertMessage( + payload, + parameters: parameters, + targetID: targetID, + modelContext: modelContext + ) + return Effects() + case let .messageRepeatCountUpdated(count, timestamp): + await updateRepeatCount( + count, + timestamp: timestamp, + targetID: targetID, + modelContext: modelContext + ) + return Effects() + case .messagesCleared: + return await clear(targetID: targetID, modelContext: modelContext) + case .unknown: + return Effects() + } + } + + package nonisolated(nonsending) func resetForReplay(modelContext: WebInspectorModelContext) async { + _ = removeMessages(targetID: nil) + let reset = prepareEmptyQueryIndexReset() + await finishQueryIndexReset(reset) + } + + package func prepareClearForLifecycle( + modelContext: WebInspectorModelContext + ) -> (effects: Effects, queryIndexReset: QueryIndexReset) { + _ = removeMessages(targetID: nil) + let reset = prepareEmptyQueryIndexReset() + return ( + Effects(clearedAllMessages: true), + reset + ) + } + + package nonisolated(nonsending) func finishQueryIndexReset(_ reset: QueryIndexReset) async { + let deliveries = await queryIndex.replace( + with: reset.inputs, + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + await applyConcreteDeliveries(deliveries) + } + + private nonisolated(nonsending) func insertMessage( + _ payload: Console.Message, + parameters: [RuntimeObject], + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorModelContext + ) async { + precondition(nextMessageOrdinal < Int.max, "ConsoleMessage identity ordinal overflowed.") + let id = ConsoleMessage.ID(nextMessageOrdinal) + nextMessageOrdinal += 1 + let message = ConsoleMessage( + id: id, + message: payload, + parameters: parameters, + targetID: targetID, + modelContext: modelContext + ) + messagesByID[id] = message + orderIndicesByID[id] = orderedMessageIDs.count + orderedMessageIDs.append(id) + lastMessageID = id + if let targetID { + lastMessageIDByTargetID[targetID] = id + } + await notifyMessageInserted(message, modelContext: modelContext) + } + + private nonisolated(nonsending) func updateRepeatCount( + _ count: Int, + timestamp: Double?, + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorModelContext + ) async { + let candidateID: ConsoleMessage.ID? + if let targetID { + candidateID = lastMessageIDByTargetID[targetID] + } else { + candidateID = lastMessageID + } + guard let candidateID, + let message = messagesByID[candidateID] else { + skipEvent("Console.messageRepeatCountUpdated arrived before any tracked message") + return + } + message.updateRepeatCount(count, timestamp: timestamp) + await notifyMessageMutated(message, modelContext: modelContext) + } + + private nonisolated(nonsending) func clear( + targetID: WebInspectorTarget.ID?, + modelContext: WebInspectorModelContext + ) async -> Effects { + let removedMessages = removeMessages(targetID: targetID) + let runtimeObjectsToUnregister = targetID == nil + ? [] + : unreferencedRuntimeObjects(from: removedMessages) + let reset = targetID == nil + ? prepareEmptyQueryIndexReset() + : prepareQueryIndexReset() + await finishQueryIndexReset(reset) + return Effects( + runtimeObjectsToUnregister: runtimeObjectsToUnregister, + clearedAllMessages: targetID == nil + ) + } + + @discardableResult + private func removeMessages(targetID: WebInspectorTarget.ID?) -> [ConsoleMessage] { + queryIndexNeedsRebuild = false + guard let targetID else { + let removedMessages = Array(messagesByID.values) + messagesByID = [:] + orderedMessageIDs = [] + orderIndicesByID = [:] + lastMessageID = nil + lastMessageIDByTargetID = [:] + return removedMessages + } + + let removedMessages = messagesByID.values.filter { $0.targetID == targetID } + guard removedMessages.isEmpty == false else { + lastMessageIDByTargetID[targetID] = nil + return [] + } + let removedIDs = Set(removedMessages.map(\.id)) + for id in removedIDs { + messagesByID[id] = nil + orderIndicesByID[id] = nil + } + orderedMessageIDs.removeAll { removedIDs.contains($0) } + rebuildOrderIndices() + if let lastMessageID, removedIDs.contains(lastMessageID) { + self.lastMessageID = orderedMessageIDs.last + } + lastMessageIDByTargetID[targetID] = orderedMessageIDs.last { id in + messagesByID[id]?.targetID == targetID + } + return removedMessages + } + + private func unreferencedRuntimeObjects( + from removedMessages: [ConsoleMessage] + ) -> [RuntimeObject] { + let removedObjectsByID = Dictionary( + removedMessages.flatMap(\.parameters).map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + guard removedObjectsByID.isEmpty == false else { + return [] + } + let remainingObjectIDs = Set(messagesByID.values.flatMap(\.parameters).map(\.id)) + return removedObjectsByID.compactMap { id, object in + remainingObjectIDs.contains(id) ? nil : object + } + } + + private func rebuildOrderIndices() { + orderIndicesByID = Dictionary( + uniqueKeysWithValues: orderedMessageIDs.enumerated().map { index, id in + (id, index) + } + ) + } + + private func currentRecordInputs() -> [ConsoleMessageRecordInput] { +#if DEBUG + performanceCounters.fullRecordProjectionCount += orderedMessageIDs.count +#endif + return orderedMessageIDs.enumerated().compactMap { index, id in + messagesByID[id].map { ConsoleMessageRecordInput(message: $0, orderIndex: index) } + } + } + + private func recordInput(for message: ConsoleMessage) -> ConsoleMessageRecordInput { +#if DEBUG + performanceCounters.incrementalRecordProjectionCount += 1 +#endif + let orderIndex = orderIndicesByID[message.id] ?? orderedMessageIDs.count + return ConsoleMessageRecordInput(message: message, orderIndex: orderIndex) + } + + private func isCurrent(_ message: ConsoleMessage) -> Bool { + return messagesByID[message.id] === message + } + + private func nextQueryIndexSequence() -> UInt64 { + precondition( + queryIndexSequence < UInt64.max, + "ConsoleMessageIndex mutation sequence overflowed." + ) + queryIndexSequence += 1 + return queryIndexSequence + } + + private func prepareQueryIndexReset() -> QueryIndexReset { + precondition( + querySourceEpoch < UInt64.max, + "Console query source epoch overflowed." + ) + querySourceEpoch += 1 + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() + return QueryIndexReset( + inputs: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + } + + private func prepareEmptyQueryIndexReset() -> QueryIndexReset { + let reset = prepareQueryIndexReset() + publishEmptyConcreteQueryResults(for: reset) + return reset + } + + private func publishEmptyConcreteQueryResults(for reset: QueryIndexReset) { + precondition( + reset.inputs.isEmpty, + "Only a full Console lifecycle reset can publish an immediate empty query state." + ) + pruneConcreteQueryRegistrations() + let projection = ConsoleMessageIndex.QueryProjection( + sourceEpoch: reset.sourceEpoch, + sequence: reset.sequence, + snapshot: WebInspectorFetchedResultsSnapshot(), + reconfigureItemIDs: [] + ) + for registration in concreteQueryRegistrations.values { + registration.results.value?.applyConsoleQueryProjection( + projection, + query: registration.activeQuery, + generation: registration.activeGeneration, + isReplacement: false, + lookup: { _ in + preconditionFailure("An empty Console reset cannot resolve a model identity.") + } + ) + } + } + + private nonisolated(nonsending) func syncQueryIndexIfNeeded() async { + guard queryIndexNeedsRebuild else { + return + } + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() + let deliveries = await queryIndex.replace( + with: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + await applyConcreteDeliveries(deliveries) + } + + private nonisolated(nonsending) func notifyMessageInserted( + _ message: ConsoleMessage, + modelContext: WebInspectorModelContext + ) async { + await syncQueryIndexIfNeeded() + guard isCurrent(message) else { + return + } + let sequence = nextQueryIndexSequence() + let input = recordInput(for: message) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries) + guard isCurrent(message) else { + return + } + } + + private nonisolated(nonsending) func notifyMessageMutated( + _ message: ConsoleMessage, + modelContext: WebInspectorModelContext + ) async { + await syncQueryIndexIfNeeded() + guard isCurrent(message) else { + return + } + let sequence = nextQueryIndexSequence() + let input = recordInput(for: message) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries) + guard isCurrent(message) else { + return + } + } + + private func allocateConcreteQueryRegistrationID() -> WebInspectorQueryRegistrationID { + precondition( + nextConcreteQueryRegistrationID < UInt64.max, + "Console concrete query registration identity overflowed." + ) + let id = WebInspectorQueryRegistrationID(rawValue: nextConcreteQueryRegistrationID) + nextConcreteQueryRegistrationID += 1 + return id + } + + private nonisolated(nonsending) func applyConcreteDeliveries(_ deliveries: [ConsoleMessageIndex.QueryDelivery]) async { + let acknowledgements = applyConcreteDeliveriesSynchronously(deliveries) + await IndexAcknowledgementWork( + index: queryIndex, + entries: acknowledgements + ).run() + } + + private func applyConcreteDeliveriesSynchronously( + _ deliveries: [ConsoleMessageIndex.QueryDelivery] + ) -> [IndexAcknowledgementWork.Entry] { + pruneConcreteQueryRegistrations() + var acknowledgements: [IndexAcknowledgementWork.Entry] = [] + for delivery in deliveries { + guard delivery.projection.sourceEpoch == querySourceEpoch else { + continue + } + let id = delivery.registrationID + if var initialization = initializingConcreteQueries[id], + initialization.generation == delivery.generation { + initialization.projection = coalesce( + initialization.projection, + with: delivery.projection + ) + initializingConcreteQueries[id] = initialization + continue + } + guard var registration = concreteQueryRegistrations[id] else { + continue + } + if delivery.generation == registration.activeGeneration { + guard let results = registration.results.value else { + concreteQueryRegistrations[id] = nil + continue + } + let applied = results.applyConsoleQueryProjection( + delivery.projection, + query: registration.activeQuery, + generation: delivery.generation, + isReplacement: false, + lookup: { id in self.messageForResult(id) } + ) + if applied { + acknowledgements.append(IndexAcknowledgementWork.Entry( + id: id, + generation: delivery.generation, + sourceEpoch: delivery.projection.sourceEpoch, + sequence: delivery.projection.sequence + )) + } + } else if var candidate = registration.candidate, + candidate.generation == delivery.generation { + candidate.projection = coalesce(candidate.projection, with: delivery.projection) + registration.candidate = candidate + } else if var committing = registration.committing[delivery.generation] { + committing.projection = coalesce( + committing.projection, + with: delivery.projection + ) + registration.committing[delivery.generation] = committing + } + concreteQueryRegistrations[id] = registration + } + return acknowledgements + } + + private func buffer( + _ projection: ConsoleMessageIndex.QueryProjection, + for id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: ConsoleQuery, + destination: ConcreteQueryBufferDestination + ) { + guard var registration = concreteQueryRegistrations[id] else { + return + } + switch destination { + case .candidate: + guard var candidate = registration.candidate, + candidate.generation == generation, + candidate.query == query else { + return + } + candidate.projection = coalesce(candidate.projection, with: projection) + registration.candidate = candidate + case .committing: + guard var committing = registration.committing[generation], + committing.query == query else { + return + } + committing.projection = coalesce(committing.projection, with: projection) + registration.committing[generation] = committing + } + concreteQueryRegistrations[id] = registration + } + + private func clearCandidate( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + guard var registration = concreteQueryRegistrations[id], + registration.candidate?.generation == generation else { + return + } + registration.candidate = nil + concreteQueryRegistrations[id] = registration + } + + private func clearCommitting( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + guard var registration = concreteQueryRegistrations[id] else { + return + } + registration.committing[generation] = nil + concreteQueryRegistrations[id] = registration + } + + private func coalesce( + _ current: ConsoleMessageIndex.QueryProjection?, + with incoming: ConsoleMessageIndex.QueryProjection + ) -> ConsoleMessageIndex.QueryProjection { + guard let current else { + return incoming + } + let incomingIsNewer = incoming.sourceEpoch > current.sourceEpoch + || (incoming.sourceEpoch == current.sourceEpoch && incoming.sequence > current.sequence) + let newest = incomingIsNewer ? incoming : current + let reconfigureItemIDs = current.reconfigureItemIDs + .union(incoming.reconfigureItemIDs) + .intersection(newest.snapshot.itemIDs) + return ConsoleMessageIndex.QueryProjection( + sourceEpoch: newest.sourceEpoch, + sequence: newest.sequence, + snapshot: newest.snapshot, + reconfigureItemIDs: reconfigureItemIDs + ) + } + + private func pruneConcreteQueryRegistrations() { + concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in + registration.results.value != nil + } + } + +#if DEBUG + package nonisolated(nonsending) func concreteQueryRegistrationCountForTesting() async -> Int { + return await queryIndex.queryRegistrationCountForTesting() + } +#endif + + private func messageForResult(_ id: ConsoleMessage.ID) -> ConsoleMessage? { +#if DEBUG + performanceCounters.resultIdentityLookupCount += 1 +#endif + return messagesByID[id] + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } +} diff --git a/Sources/WebInspectorDataKit/ConsoleQuery.swift b/Sources/WebInspectorDataKit/ConsoleQuery.swift new file mode 100644 index 00000000..169cc03e --- /dev/null +++ b/Sources/WebInspectorDataKit/ConsoleQuery.swift @@ -0,0 +1,69 @@ +import WebInspectorProxyKit + +/// A closed query for live Console message results. +public struct ConsoleQuery: Sendable, Equatable { + /// Message levels to include, or an empty set to include every level. + public var levels: Set + + /// The ordering applied before offset and limit. + public var sort: ConsoleSort + + /// The optional grouping applied to visible messages. + public var section: ConsoleSection? + + /// The number of matching messages skipped before publication. + public var offset: Int { + didSet { + Self.validate(offset: offset) + } + } + + /// The maximum number of messages published after the offset, or `nil` for no limit. + public var limit: Int? { + didSet { + Self.validate(limit: limit) + } + } + + /// Creates a Console message query. + public init( + levels: Set = [], + sort: ConsoleSort = .insertionAscending, + section: ConsoleSection? = nil, + offset: Int = 0, + limit: Int? = nil + ) { + Self.validate(offset: offset) + Self.validate(limit: limit) + self.levels = levels + self.sort = sort + self.section = section + self.offset = offset + self.limit = limit + } + + private static func validate(offset: Int) { + precondition(offset >= 0, "ConsoleQuery offset must be non-negative.") + } + + private static func validate(limit: Int?) { + if let limit { + precondition(limit >= 0, "ConsoleQuery limit must be non-negative.") + } + } +} + +/// Supported Console message ordering. +public enum ConsoleSort: Sendable, Equatable { + /// Preserves insertion order from oldest to newest. + case insertionAscending + + /// Reverses insertion order from newest to oldest. + case insertionDescending +} + +/// Supported Console message grouping. +public enum ConsoleSection: Sendable, Equatable { + /// Groups visible messages by Console level. + case level +} diff --git a/Sources/WebInspectorDataKit/DOMMutation.swift b/Sources/WebInspectorDataKit/DOMMutation.swift new file mode 100644 index 00000000..50adef76 --- /dev/null +++ b/Sources/WebInspectorDataKit/DOMMutation.swift @@ -0,0 +1,59 @@ +/// Controls how DOM selection changes should be revealed to UI tree views. +public enum DOMRevealPolicy: Sendable, Hashable { + /// Do not reveal or select the node. + case none + + /// Select the node without requesting scrolling. + case selectOnly + + /// Select and request scrolling the node into view. + case selectAndScroll +} + +/// One node-specific failure from a multi-node DOM mutation. +public struct DOMMutationFailure: Error, Hashable, Sendable { + public let nodeID: DOMNode.ID + public let message: String + + public init(nodeID: DOMNode.ID, message: String) { + self.nodeID = nodeID + self.message = message + } +} + +/// A document-epoch-bound capability for undoing one accepted DOM/CSS change. +public final class DOMUndoCapability { + private let commands: WebInspectorModelContext.DOMUndoRedoCommands + + package init(commands: WebInspectorModelContext.DOMUndoRedoCommands) { + self.commands = commands + } + + public nonisolated(nonsending) func undo() async throws { + try await commands.undo() + } + + public nonisolated(nonsending) func redo() async throws { + try await commands.redo() + } +} + +/// The applied subset and explicit failures from a requested DOM mutation. +public struct DOMMutationOutcome { + public let requestedNodeIDs: [DOMNode.ID] + public let appliedNodeIDs: [DOMNode.ID] + public let failures: [DOMMutationFailure] + public let undo: DOMUndoCapability? + + public init( + requestedNodeIDs: [DOMNode.ID], + appliedNodeIDs: [DOMNode.ID], + failures: [DOMMutationFailure], + undo: DOMUndoCapability? + ) { + self.requestedNodeIDs = requestedNodeIDs + self.appliedNodeIDs = appliedNodeIDs + self.failures = failures + self.undo = undo + } +} diff --git a/Sources/WebInspectorDataKit/DOMMutationPolicy.swift b/Sources/WebInspectorDataKit/DOMMutationPolicy.swift new file mode 100644 index 00000000..19a33ad1 --- /dev/null +++ b/Sources/WebInspectorDataKit/DOMMutationPolicy.swift @@ -0,0 +1,17 @@ +/// Closed internal policy passed through one mutation transaction. +package struct DOMMutationPolicy: Sendable, Hashable { + package let undo: WebInspectorUndoPolicy + + package init(undo: WebInspectorUndoPolicy = .automatic) { + self.undo = undo + } +} + +/// Controls whether a mutation participates in WebKit inspector undo history. +public enum WebInspectorUndoPolicy: Sendable, Hashable { + /// Let DataKit record undoable WebKit DOM mutations where supported. + case automatic + + /// Do not record an undo checkpoint for the mutation. + case disabled +} diff --git a/Sources/WebInspectorDataKit/DOMNode.swift b/Sources/WebInspectorDataKit/DOMNode.swift index 536fdf96..3a2f1939 100644 --- a/Sources/WebInspectorDataKit/DOMNode.swift +++ b/Sources/WebInspectorDataKit/DOMNode.swift @@ -2,7 +2,7 @@ import Foundation import Observation import WebInspectorProxyKit -/// Observable model for a DOM node owned by a ``WebInspectorContext``. +/// Observable model for a DOM node owned by a ``WebInspectorModelContext``. @Observable public final class DOMNode: WebInspectorPersistentModel { /// Stable identity for a DOM node within a context. @@ -170,9 +170,7 @@ public final class DOMNode: WebInspectorPersistentModel { return normalizedName == "iframe" || normalizedName == "frame" } - @ObservationIgnored weak var modelContext: WebInspectorContext? - - init(node: DOM.Node, modelContext: WebInspectorContext) { + init(node: DOM.Node) { id = ID(node.id) nodeName = node.nodeName localName = node.localName @@ -194,45 +192,6 @@ public final class DOMNode: WebInspectorPersistentModel { pseudoType = node.pseudoType shadowRootType = node.shadowRootType elementStyles = nil - self.modelContext = modelContext - } - - /// Requests regular child nodes for this node. - public func requestChildren( - depth: Int = 1, - isolation: isolated (any Actor) = #isolation - ) async { - guard let modelContext else { - preconditionFailure("DOMNode is not registered in a WebInspectorContext.") - } - await modelContext.requestChildren(for: self, depth: depth, isolation: isolation) - } - - /// Returns copied text for the node in the requested format. - public func copyText( - _ kind: CopyTextKind, - isolation: isolated (any Actor) = #isolation - ) async throws -> String { - guard let modelContext else { - preconditionFailure("DOMNode is not registered in a WebInspectorContext.") - } - return try await modelContext.copyText(kind, for: self, isolation: isolation) - } - - /// Removes this node from the inspected document. - public func delete(isolation: isolated (any Actor) = #isolation) async throws { - guard let modelContext else { - preconditionFailure("DOMNode is not registered in a WebInspectorContext.") - } - try await modelContext.delete(self, isolation: isolation) - } - - /// Highlights this node in the inspected page. - public func highlight(isolation: isolated (any Actor) = #isolation) async throws { - guard let modelContext else { - preconditionFailure("DOMNode is not registered in a WebInspectorContext.") - } - try await modelContext.highlight(self, isolation: isolation) } func update(from node: DOM.Node) { @@ -369,9 +328,6 @@ public final class DOMNode: WebInspectorPersistentModel { .compactMap { $0 } } - func setModelContext(_ context: WebInspectorContext) { - modelContext = context - } } extension DOMNode.Attribute { diff --git a/Sources/WebInspectorDataKit/DOMStateStore.swift b/Sources/WebInspectorDataKit/DOMStateStore.swift new file mode 100644 index 00000000..e53f5d4e --- /dev/null +++ b/Sources/WebInspectorDataKit/DOMStateStore.swift @@ -0,0 +1,1193 @@ +import WebInspectorProxyKit + +/// Owns the semantic DOM graph and every mutation that can change it. +/// +/// `WebInspectorModelContext` remains the attachment and I/O coordinator. It gives +/// this store protocol payloads and applies the explicit effects returned by +/// the store. The store itself never starts transport work. +package final class DOMStateStore { + package struct FrameDocumentLoadRequest: Equatable { + package let targetID: WebInspectorTarget.ID + package let reason: String + } + + package struct Effects { + package var statusChanged = false + package var selectionChanged = false + package var selectedStylesNeedRefresh = false + package var documentReset = false + package var shouldReloadDocument = false + package var shouldClearPageHighlight = false + package var inspectedNode: DOMNode? + package var discardedStyleNode: DOMNode? + package var frameDocumentLoadRequests: [FrameDocumentLoadRequest] = [] + + package mutating func merge(_ other: Effects) { + statusChanged = statusChanged || other.statusChanged + selectionChanged = selectionChanged || other.selectionChanged + selectedStylesNeedRefresh = selectedStylesNeedRefresh || other.selectedStylesNeedRefresh + documentReset = documentReset || other.documentReset + shouldReloadDocument = shouldReloadDocument || other.shouldReloadDocument + shouldClearPageHighlight = shouldClearPageHighlight || other.shouldClearPageHighlight + if let inspectedNode = other.inspectedNode { + self.inspectedNode = inspectedNode + } + if let discardedStyleNode = other.discardedStyleNode { + self.discardedStyleNode = discardedStyleNode + } + frameDocumentLoadRequests.append(contentsOf: other.frameDocumentLoadRequests) + } + } + + package private(set) var rootNode: DOMNode? + package private(set) var selectedNode: DOMNode? + package private(set) var isElementPickerEnabled: Bool + package private(set) var documentEpoch: Int + package private(set) var selectionRevision: UInt64 + + private var nodesByID: [DOMNode.ID: DOMNode] + private var frameDocumentProjectionIndex: FrameDocumentProjectionIndex + private var treeStates: [WeakDOMTreeState] + private var pendingInspectedNodeID: DOMNode.ID? + private var pageHighlightDocumentEpoch: Int? + private var editHistoryTarget: WebInspectorTarget? + private var didInvalidateEditHistoryTarget: Bool + + package init() { + rootNode = nil + selectedNode = nil + isElementPickerEnabled = false + documentEpoch = 0 + selectionRevision = 0 + nodesByID = [:] + frameDocumentProjectionIndex = FrameDocumentProjectionIndex() + treeStates = [] + pendingInspectedNodeID = nil + pageHighlightDocumentEpoch = nil + editHistoryTarget = nil + didInvalidateEditHistoryTarget = false + } + + package func node( + for id: DOMNode.ID + ) -> DOMNode? { + return nodesByID[id] + } + + package func requiredNode( + for id: DOMNode.ID + ) throws -> DOMNode { + guard let node = nodesByID[id] else { + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorModelContext.") + } + return node + } + + @discardableResult + package func registeredNode( + _ node: DOMNode + ) throws -> DOMNode { + guard nodesByID[node.id] === node else { + throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorModelContext.") + } + return node + } + + package func styles( + containing property: CSSStyleProperty + ) -> CSSStyles? { + nodesByID.values.lazy.compactMap(\.elementStyles).first { + $0.contains(property: property) + } + } + + package func styles( + containing ruleID: CSSStyleRule.ID + ) -> CSSStyles? { + nodesByID.values.lazy.compactMap(\.elementStyles).first { + $0.contains(ruleID: ruleID) + } + } + + package func markAllStylesNeedsRefresh() { + for styles in nodesByID.values.compactMap(\.elementStyles) { + styles.markNeedsRefresh() + } + } + + package func select( + _ node: DOMNode?, + reveal: DOMRevealPolicy + ) -> Effects { + if let node, nodesByID[node.id] !== node { + preconditionFailure("DOMNode is not registered in this WebInspectorModelContext.") + } + advanceSelectionRevision() + pendingInspectedNodeID = nil + selectedNode = node + notifyDOMTreeSelectionChanged(node, reveal: reveal) + return Effects(statusChanged: true, selectionChanged: true) + } + + package func treeController( + root requestedRoot: DOMNode? + ) throws -> DOMTreeController { + guard let root = requestedRoot ?? rootNode else { + throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no DOM root node.") + } + guard nodesByID[root.id] === root else { + preconditionFailure("DOMTreeController root is not registered in this WebInspectorModelContext.") + } + + let tree = DOMTreeState(rootNode: root, selectedNode: selectedNode) + treeStates.append(WeakDOMTreeState(tree)) + pruneReleasedTreeStates() + return DOMTreeController(tree: tree) + } + + package func rootTreeController( + ) -> DOMTreeController { + let tree = DOMTreeState(rootNode: rootNode, selectedNode: selectedNode) + treeStates.append(WeakDOMTreeState(tree)) + pruneReleasedTreeStates() + return DOMTreeController(tree: tree) + } + + package func currentTreeSnapshot( + ) -> DOMTreeSnapshot { + return DOMTreeSnapshot.make(revision: 0, rootNode: rootNode, selectedNode: selectedNode) + } + + package func currentTreeSnapshot( + containing nodes: [DOMNode] + ) throws -> DOMTreeSnapshot { + let snapshot = currentTreeSnapshot() + for node in nodes where snapshot.node(for: node.id) == nil { + throw WebInspectorProxyError.disconnected("DOMNode is not in the current DOM tree.") + } + return snapshot + } + + package func sortedDeletionNodes( + for nodeIDs: [DOMNode.ID] + ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { + var seenNodeIDs: Set = [] + let nodes = try nodeIDs + .map { try requiredNode(for: $0) } + .filter { seenNodeIDs.insert($0.id).inserted } + let snapshot = try currentTreeSnapshot(containing: nodes) + let sortedNodes = nodes.sorted { + snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count + } + return (sortedNodes, snapshot) + } + + package func sortedDeletionNodes( + for nodes: [DOMNode] + ) throws -> (nodes: [DOMNode], snapshot: DOMTreeSnapshot) { + var seenNodeIDs: Set = [] + let registeredNodes = try nodes + .map { try registeredNode($0) } + .filter { seenNodeIDs.insert($0.id).inserted } + let snapshot = try currentTreeSnapshot(containing: registeredNodes) + let sortedNodes = registeredNodes.sorted { + snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count + } + return (sortedNodes, snapshot) + } + + package func clearSelectionIfDeleted( + _ deletedRootIDs: [DOMNode.ID], + snapshot: DOMTreeSnapshot + ) -> Effects { + guard let selectedNode else { + return Effects() + } + let deletedRootIDs = Set(deletedRootIDs) + guard deletedRootIDs.contains(selectedNode.id) + || snapshot.ancestorNodeIDs(of: selectedNode.id).contains(where: deletedRootIDs.contains) + else { + return Effects() + } + + self.selectedNode = nil + notifyDOMTreeSelectionChanged(nil) + return Effects( + statusChanged: true, + selectionChanged: true, + discardedStyleNode: selectedNode + ) + } + + package func setElementPickerEnabled( + _ enabled: Bool + ) -> Effects { + isElementPickerEnabled = enabled + return Effects(statusChanged: true) + } + + package func recordPageHighlight( + ) { + pageHighlightDocumentEpoch = documentEpoch + } + + package func clearPageHighlight( + ) { + pageHighlightDocumentEpoch = nil + } + + package func shouldSendPageHighlightClearAfterReset( + ) -> Bool { + return pageHighlightDocumentEpoch == nil + } + + package func recordEditHistoryTarget( + _ target: WebInspectorTarget, + options: DOMMutationPolicy + ) { + guard options.undo == .automatic else { + return + } + editHistoryTarget = target + didInvalidateEditHistoryTarget = false + } + + package func undoRedoTarget( + capturedTarget: WebInspectorTarget?, + fallbackTarget: WebInspectorTarget?, + documentEpoch capturedDocumentEpoch: Int + ) throws -> WebInspectorTarget { + guard documentEpoch == capturedDocumentEpoch else { + throw WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.") + } + if let capturedTarget { + return capturedTarget + } + if let editHistoryTarget { + return editHistoryTarget + } + guard didInvalidateEditHistoryTarget == false, + let fallbackTarget else { + throw WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.") + } + return fallbackTarget + } + + package func capturedEditHistoryTarget( + ) -> WebInspectorTarget? { + return editHistoryTarget + } + + @discardableResult + package func advanceDocumentEpoch( + ) -> Int { + documentEpoch += 1 + return documentEpoch + } + + package func resetDocument( + ) -> Effects { + advanceSelectionRevision() + let shouldClearPageHighlight = pageHighlightDocumentEpoch != nil + rootNode = nil + selectedNode = nil + if editHistoryTarget != nil { + didInvalidateEditHistoryTarget = true + } + editHistoryTarget = nil + isElementPickerEnabled = false + pendingInspectedNodeID = nil + nodesByID = [:] + frameDocumentProjectionIndex.removeAll() + pageHighlightDocumentEpoch = nil + notifyDOMTreeSnapshot(reason: .reset) + return Effects( + statusChanged: true, + selectionChanged: true, + documentReset: true, + shouldClearPageHighlight: shouldClearPageHighlight + ) + } + + package func apply( + _ event: DOM.Event, + modelContext: WebInspectorModelContext + ) -> Effects { + var effects = Effects() + switch event { + case .documentUpdated: + advanceDocumentEpoch() + effects.merge(resetDocument()) + effects.shouldReloadDocument = true + case let .setChildNodes(parent, nodes): + effects.merge(applySetChildNodes( + parent: parent, + nodes: nodes, + modelContext: modelContext + )) + case let .childNodeInserted(parent, previous, node): + effects.merge(applyChildNodeInserted( + parent: parent, + previous: previous, + node: node, + modelContext: modelContext + )) + case let .childNodeRemoved(parent, node): + effects.merge(applyChildNodeRemoved(parent: parent, node: node)) + case let .childNodeCountUpdated(id, count): + guard let node = nodesByID[DOMNode.ID(id)] else { + requestFrameDocumentIfNeeded( + forNodeID: DOMNode.ID(id), + reason: "DOM.childNodeCountUpdated", + effects: &effects + ) + skipEvent("DOM.childNodeCountUpdated referenced unmaterialized node id=\(logDescription(id))") + return effects + } + node.updateChildNodeCount(count) + notifyDOMTreeChildCountChanged(node: node) + case let .attributeModified(id, name, value): + guard let node = nodesByID[DOMNode.ID(id)] else { + requestFrameDocumentIfNeeded( + forNodeID: DOMNode.ID(id), + reason: "DOM.attributeModified", + effects: &effects + ) + skipEvent("DOM.attributeModified referenced unmaterialized node id=\(logDescription(id))") + return effects + } + node.setAttribute(name: name, value: value) + effects.selectedStylesNeedRefresh = selectedNode?.id == node.id + notifyDOMTreeNodeChanged(node) + case let .attributeRemoved(id, name): + guard let node = nodesByID[DOMNode.ID(id)] else { + requestFrameDocumentIfNeeded( + forNodeID: DOMNode.ID(id), + reason: "DOM.attributeRemoved", + effects: &effects + ) + skipEvent("DOM.attributeRemoved referenced unmaterialized node id=\(logDescription(id))") + return effects + } + node.removeAttribute(name: name) + effects.selectedStylesNeedRefresh = selectedNode?.id == node.id + notifyDOMTreeNodeChanged(node) + case let .inlineStyleInvalidated(ids): + effects.selectedStylesNeedRefresh = ids.isEmpty + || ids.contains(where: { selectedNode?.id == DOMNode.ID($0) }) + case let .characterDataModified(id, value): + guard let node = nodesByID[DOMNode.ID(id)] else { + requestFrameDocumentIfNeeded( + forNodeID: DOMNode.ID(id), + reason: "DOM.characterDataModified", + effects: &effects + ) + skipEvent("DOM.characterDataModified referenced unmaterialized node id=\(logDescription(id))") + return effects + } + node.setNodeValue(value) + effects.selectedStylesNeedRefresh = selectedNode?.id == node.id + notifyDOMTreeNodeChanged(node) + case let .inspect(id): + isElementPickerEnabled = false + effects.statusChanged = true + let inspectedNodeID = DOMNode.ID(id) + guard let node = nodesByID[inspectedNodeID], isNodeAttachedToCurrentTree(node) else { + advanceSelectionRevision() + requestFrameDocumentIfNeeded( + forNodeID: inspectedNodeID, + reason: "DOM.inspect", + effects: &effects + ) + pendingInspectedNodeID = inspectedNodeID + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: true + )) + return effects + } + pendingInspectedNodeID = nil + effects.merge(select(node, reveal: .selectAndScroll)) + effects.inspectedNode = node + case .detachedRoot: + skipEvent("DOM.setChildNodes detached root deferred; subtree not indexed") + case let .shadowRootPushed(host, root): + effects.merge(applyShadowRootPushed( + host: host, + root: root, + modelContext: modelContext + )) + case let .shadowRootPopped(host, root): + effects.merge(applyShadowRootPopped(host: host, root: root)) + case let .pseudoElementAdded(parent, element): + effects.merge(applyPseudoElementAdded( + parent: parent, + element: element, + modelContext: modelContext + )) + case let .pseudoElementRemoved(parent, element): + effects.merge(applyPseudoElementRemoved(parent: parent, element: element)) + case .willDestroyDOMNode, + .unknown: + break + } + return effects + } + + private func advanceSelectionRevision() { + precondition( + selectionRevision < UInt64.max, + "DOM selection revision exhausted UInt64." + ) + selectionRevision += 1 + } + + @discardableResult + package func applyDocument( + _ payload: DOM.Node, + expectedEpoch: Int, + reason: DOMTreeSnapshotReason, + modelContext: WebInspectorModelContext + ) -> Effects? { + guard expectedEpoch == documentEpoch else { + return nil + } + var effects = Effects() + var materializedPayloadIDs = Set() + collectMaterializedPayloadIDs(payload, into: &materializedPayloadIDs) + rootNode = model( + for: payload, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + notifyDOMTreeSnapshot(reason: reason) + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: true + )) + return effects + } + + @discardableResult + package func applyFrameDocument( + _ document: DOM.Node, + frameTargetID: WebInspectorTarget.ID, + expectedEpoch: Int, + modelContext: WebInspectorModelContext + ) -> Effects? { + guard expectedEpoch == documentEpoch else { + return nil + } + var effects = Effects() + let scopedDocument = scopedFrameDocument(document, to: frameTargetID) + var materializedPayloadIDs = Set() + collectMaterializedPayloadIDs(scopedDocument, into: &materializedPayloadIDs) + let previousRootID = frameDocumentProjectionIndex.setFrameDocumentRootID( + DOMNode.ID(scopedDocument.id), + for: frameTargetID + ) + let frameRoot = model( + for: scopedDocument, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + if let previousRootID, + previousRootID != frameRoot.id, + let previousRoot = nodesByID[previousRootID] { + removeSubtreeFromIndex(previousRoot, preserving: materializedPayloadIDs, effects: &effects) + } + + if let owner = attachProjectedFrameDocumentRoot(frameRoot, frameTargetID: frameTargetID) { + notifyDOMTreeChildrenReplaced(parent: owner) + } + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: true + )) + return effects + } + + package func detachProjectedFrameDocument( + forFrameID frameID: FrameID + ) -> Effects { + var effects = Effects() + let owners = nodesByID.values.filter { $0.isFrameOwner && $0.frameID == frameID } + for owner in owners { + guard let rootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: owner.id) else { + continue + } + let root = nodesByID[rootID] + frameDocumentProjectionIndex.detachProjection(attachedTo: owner.id) + owner.setContentDocument(nil) + if let root { + removeSubtreeFromIndex(root, effects: &effects) + } + notifyDOMTreeChildrenReplaced(parent: owner) + } + return effects + } + + private func applySetChildNodes( + parent: DOM.Node.ID, + nodes: [DOM.Node], + modelContext: WebInspectorModelContext + ) -> Effects { + var effects = Effects() + guard let parentNode = nodesByID[DOMNode.ID(parent)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(parent), reason: "DOM.setChildNodes", effects: &effects) + skipEvent("DOM.setChildNodes referenced unmaterialized parent id=\(logDescription(parent))") + return effects + } + let previousChildren: [DOMNode] + if case let .loaded(children) = parentNode.children { + previousChildren = children + } else { + previousChildren = [] + } + var newSubtreeIDs = Set() + for node in nodes { + collectMaterializedPayloadIDs(node, into: &newSubtreeIDs) + } + let newChildren = nodes.map { + model( + for: $0, + preserving: newSubtreeIDs, + modelContext: modelContext, + effects: &effects + ) + } + let newChildIDs = Set(newChildren.map(\.id)) + for previousChild in previousChildren where newChildIDs.contains(previousChild.id) == false { + removeSubtreeFromIndex(previousChild, preserving: newSubtreeIDs, effects: &effects) + } + parentNode.setChildren(newChildren) + notifyDOMTreeChildrenReplaced(parent: parentNode) + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: false + )) + return effects + } + + private func applyChildNodeInserted( + parent: DOM.Node.ID, + previous: DOM.Node.ID?, + node: DOM.Node, + modelContext: WebInspectorModelContext + ) -> Effects { + var effects = Effects() + guard let parentNode = nodesByID[DOMNode.ID(parent)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(parent), reason: "DOM.childNodeInserted", effects: &effects) + skipEvent("DOM.childNodeInserted referenced unmaterialized parent id=\(logDescription(parent))") + return effects + } + + guard case var .loaded(children) = parentNode.children else { + parentNode.updateChildNodeCount(parentNode.childNodeCount + 1) + notifyDOMTreeChildCountChanged(node: parentNode) + return effects + } + var materializedPayloadIDs = Set() + collectMaterializedPayloadIDs(node, into: &materializedPayloadIDs) + let inserted = model( + for: node, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + if let previous, let index = children.firstIndex(where: { $0.id == DOMNode.ID(previous) }) { + children.insert(inserted, at: children.index(after: index)) + } else { + children.insert(inserted, at: 0) + } + parentNode.setChildren(children) + notifyDOMTreeChildInserted( + parent: parentNode, + node: inserted, + previousSiblingID: previous.map(DOMNode.ID.init) + ) + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: false + )) + return effects + } + + private func applyChildNodeRemoved(parent: DOM.Node.ID, node: DOM.Node.ID) -> Effects { + var effects = Effects() + guard let parentNode = nodesByID[DOMNode.ID(parent)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(parent), reason: "DOM.childNodeRemoved", effects: &effects) + skipEvent("DOM.childNodeRemoved referenced unmaterialized parent id=\(logDescription(parent))") + return effects + } + + let removedID = DOMNode.ID(node) + guard let removedNode = nodesByID[removedID] else { + requestFrameDocumentIfNeeded(forNodeID: removedID, reason: "DOM.childNodeRemoved", effects: &effects) + skipEvent("DOM.childNodeRemoved referenced unmaterialized child id=\(logDescription(node))") + return effects + } + removeSubtreeFromIndex(removedNode, effects: &effects) + + guard case let .loaded(children) = parentNode.children else { + parentNode.updateChildNodeCount(max(0, parentNode.childNodeCount - 1)) + notifyDOMTreeChildCountChanged(node: parentNode) + return effects + } + parentNode.setChildren(children.filter { $0.id != removedID }) + notifyDOMTreeChildRemoved(parent: parentNode, nodeID: removedID) + return effects + } + + private func applyShadowRootPushed( + host: DOM.Node.ID, + root payload: DOM.Node, + modelContext: WebInspectorModelContext + ) -> Effects { + var effects = Effects() + guard let hostNode = nodesByID[DOMNode.ID(host)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(host), reason: "DOM.shadowRootPushed", effects: &effects) + skipEvent("DOM.shadowRootPushed referenced unmaterialized host id=\(logDescription(host))") + return effects + } + + var materializedPayloadIDs = Set() + collectMaterializedPayloadIDs(payload, into: &materializedPayloadIDs) + let rootNode = model( + for: payload, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + hostNode.appendShadowRoot(rootNode) + notifyDOMTreeChildrenReplaced(parent: hostNode) + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: false + )) + return effects + } + + private func applyShadowRootPopped(host: DOM.Node.ID, root: DOM.Node.ID) -> Effects { + var effects = Effects() + guard let hostNode = nodesByID[DOMNode.ID(host)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(host), reason: "DOM.shadowRootPopped", effects: &effects) + skipEvent("DOM.shadowRootPopped referenced unmaterialized host id=\(logDescription(host))") + return effects + } + + let rootID = DOMNode.ID(root) + guard let removedRoot = hostNode.removeShadowRoot(id: rootID) ?? nodesByID[rootID] else { + skipEvent("DOM.shadowRootPopped referenced unmaterialized root id=\(logDescription(root))") + return effects + } + removeSubtreeFromIndex(removedRoot, effects: &effects) + notifyDOMTreeChildrenReplaced(parent: hostNode) + return effects + } + + private func applyPseudoElementAdded( + parent: DOM.Node.ID, + element payload: DOM.Node, + modelContext: WebInspectorModelContext + ) -> Effects { + var effects = Effects() + guard let parentNode = nodesByID[DOMNode.ID(parent)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(parent), reason: "DOM.pseudoElementAdded", effects: &effects) + skipEvent("DOM.pseudoElementAdded referenced unmaterialized parent id=\(logDescription(parent))") + return effects + } + + var materializedPayloadIDs = Set() + collectMaterializedPayloadIDs(payload, into: &materializedPayloadIDs) + let pseudoElement = model( + for: payload, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + if let replacedElement = parentNode.setPseudoElement(pseudoElement) { + removeSubtreeFromIndex(replacedElement, preserving: materializedPayloadIDs, effects: &effects) + } + notifyDOMTreeChildrenReplaced(parent: parentNode) + effects.merge(resolvePendingInspectedNode( + requestSubtreeIfNeeded: false + )) + return effects + } + + private func applyPseudoElementRemoved(parent: DOM.Node.ID, element: DOM.Node.ID) -> Effects { + var effects = Effects() + guard let parentNode = nodesByID[DOMNode.ID(parent)] else { + requestFrameDocumentIfNeeded(forNodeID: DOMNode.ID(parent), reason: "DOM.pseudoElementRemoved", effects: &effects) + skipEvent("DOM.pseudoElementRemoved referenced unmaterialized parent id=\(logDescription(parent))") + return effects + } + + let elementID = DOMNode.ID(element) + guard let removedElement = parentNode.removePseudoElement(id: elementID) ?? nodesByID[elementID] else { + skipEvent("DOM.pseudoElementRemoved referenced unmaterialized pseudo element id=\(logDescription(element))") + return effects + } + removeSubtreeFromIndex(removedElement, effects: &effects) + notifyDOMTreeChildrenReplaced(parent: parentNode) + return effects + } + + @discardableResult + private func removeSubtreeFromIndex( + _ root: DOMNode, + preserving preservedIDs: Set = [], + effects: inout Effects + ) -> Bool { + var removedIDs = Set() + collectSubtreeIDs(root, into: &removedIDs) + removedIDs.subtract(preservedIDs) + frameDocumentProjectionIndex.removeProjections(containing: removedIDs) + for id in removedIDs { + nodesByID[id] = nil + } + if let selectedNode, removedIDs.contains(selectedNode.id) { + self.selectedNode = nil + notifyDOMTreeSelectionChanged(nil) + effects.statusChanged = true + effects.selectionChanged = true + return true + } + return false + } + + private func collectSubtreeIDs(_ node: DOMNode, into ids: inout Set) { + guard ids.insert(node.id).inserted else { + return + } + for associatedRoot in node.associatedSubtreeRoots() { + collectSubtreeIDs(associatedRoot, into: &ids) + } + guard case let .loaded(children) = node.children else { + return + } + for child in children { + collectSubtreeIDs(child, into: &ids) + } + } + + private func collectMaterializedPayloadIDs(_ node: DOM.Node, into ids: inout Set) { + guard ids.insert(DOMNode.ID(node.id)).inserted else { + return + } + for associatedNode in associatedPayloadNodes(for: node) { + collectMaterializedPayloadIDs(associatedNode, into: &ids) + } + for child in node.children ?? [] { + collectMaterializedPayloadIDs(child, into: &ids) + } + } + + private func model( + for payload: DOM.Node, + preserving materializedPayloadIDs: Set, + modelContext: WebInspectorModelContext, + effects: inout Effects + ) -> DOMNode { + let id = DOMNode.ID(payload.id) + let node: DOMNode + let previousChildren: [DOMNode] + let previousAssociatedRoots: [DOMNode] + if let existing = nodesByID[id] { + if case let .loaded(children) = existing.children { + previousChildren = children + } else { + previousChildren = [] + } + previousAssociatedRoots = existing.associatedSubtreeRoots() + existing.update(from: payload) + node = existing + } else { + previousChildren = [] + previousAssociatedRoots = [] + node = DOMNode(node: payload) + nodesByID[id] = node + } + + let payloadContentDocument = payload.contentDocument.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let shadowRoots = payload.shadowRoots.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let templateContent = payload.templateContent.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let beforePseudoElement = payload.beforePseudoElement.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let otherPseudoElements = payload.otherPseudoElements.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let afterPseudoElement = payload.afterPseudoElement.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let contentDocument = projectedFrameDocument(for: node, payloadContentDocument: payloadContentDocument) + node.setAssociatedNodes( + contentDocument: contentDocument, + shadowRoots: shadowRoots, + templateContent: templateContent, + beforePseudoElement: beforePseudoElement, + otherPseudoElements: otherPseudoElements, + afterPseudoElement: afterPseudoElement + ) + let associatedIDs = Set(node.associatedSubtreeRoots().map(\.id)) + for previousRoot in previousAssociatedRoots where associatedIDs.contains(previousRoot.id) == false { + removeSubtreeFromIndex(previousRoot, preserving: materializedPayloadIDs, effects: &effects) + } + + if let children = payload.children { + let newChildren = children.map { + model( + for: $0, + preserving: materializedPayloadIDs, + modelContext: modelContext, + effects: &effects + ) + } + let newChildIDs = Set(newChildren.map(\.id)) + for previousChild in previousChildren where newChildIDs.contains(previousChild.id) == false { + removeSubtreeFromIndex(previousChild, preserving: materializedPayloadIDs, effects: &effects) + } + node.setChildren(newChildren) + } else if payload.childNodeCount == 0 && previousChildren.isEmpty == false { + for previousChild in previousChildren { + removeSubtreeFromIndex(previousChild, preserving: materializedPayloadIDs, effects: &effects) + } + node.setChildrenUnrequested(count: payload.childNodeCount) + } else { + node.updateChildNodeCount(payload.childNodeCount) + } + return node + } + + private func associatedPayloadNodes(for node: DOM.Node) -> [DOM.Node] { + [node.contentDocument] + .compactMap { $0 } + + node.shadowRoots + + [node.templateContent, node.beforePseudoElement] + .compactMap { $0 } + + node.otherPseudoElements + + [node.afterPseudoElement] + .compactMap { $0 } + } + + private func projectedFrameDocument( + for owner: DOMNode, + payloadContentDocument: DOMNode? + ) -> DOMNode? { + guard owner.isFrameOwner else { + return payloadContentDocument + } + + if let attachedRootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: owner.id) { + guard let attachedRoot = nodesByID[attachedRootID], + frameOwner(owner, matchesFrameDocumentRoot: attachedRoot) else { + frameDocumentProjectionIndex.detachProjection(attachedTo: owner.id) + return payloadContentDocument + } + return attachedRoot + } + + guard let frameTargetID = frameTargetIDForFrameDocument(matching: owner), + let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), + let root = nodesByID[rootID] else { + return payloadContentDocument + } + frameDocumentProjectionIndex.attach(frameTargetID: frameTargetID, to: owner.id) + return root + } + + private func frameTargetIDForFrameDocument(matching owner: DOMNode) -> WebInspectorTarget.ID? { + let matches = frameDocumentProjectionIndex.frameTargetIDs.filter { frameTargetID in + guard frameDocumentProjectionIndex.ownerNodeID(for: frameTargetID) == nil, + let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), + let root = nodesByID[rootID] else { + return false + } + return frameOwner(owner, matchesFrameDocumentRoot: root) + } + guard matches.count <= 1 else { + WebInspectorDataKitLog.debug( + "frame document projection ambiguous owner=\(String(describing: owner.id))" + ) + return nil + } + return matches.first + } + + private func frameOwner(_ owner: DOMNode, matchesFrameDocumentRoot root: DOMNode) -> Bool { + guard owner.isFrameOwner, + let ownerFrameID = owner.frameID, + let rootFrameID = root.frameID else { + return false + } + return ownerFrameID == rootFrameID + } + + private func requestFrameDocumentIfNeeded( + forNodeID nodeID: DOMNode.ID, + reason: String, + effects: inout Effects + ) { + guard let frameTargetID = frameTargetID(for: nodeID) else { + return + } + if let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), + let root = nodesByID[rootID] { + if let owner = attachProjectedFrameDocumentRoot(root, frameTargetID: frameTargetID) { + notifyDOMTreeChildrenReplaced(parent: owner) + } + return + } + if effects.frameDocumentLoadRequests.contains(where: { $0.targetID == frameTargetID }) == false { + effects.frameDocumentLoadRequests.append(FrameDocumentLoadRequest( + targetID: frameTargetID, + reason: reason + )) + } + } + + private func attachProjectedFrameDocumentRoot( + _ frameRoot: DOMNode, + frameTargetID: WebInspectorTarget.ID + ) -> DOMNode? { + guard let owner = frameOwner(forFrameDocumentRoot: frameRoot, frameTargetID: frameTargetID) else { + frameDocumentProjectionIndex.detach(frameTargetID: frameTargetID) + return nil + } + frameDocumentProjectionIndex.attach(frameTargetID: frameTargetID, to: owner.id) + owner.setContentDocument(frameRoot) + return owner + } + + private func frameOwner( + forFrameDocumentRoot frameRoot: DOMNode, + frameTargetID: WebInspectorTarget.ID + ) -> DOMNode? { + if let ownerID = frameDocumentProjectionIndex.ownerNodeID(for: frameTargetID), + let owner = nodesByID[ownerID], + frameOwner(owner, matchesFrameDocumentRoot: frameRoot) { + return owner + } + let candidates = nodesByID.values.filter { node in + guard frameOwner(node, matchesFrameDocumentRoot: frameRoot) else { + return false + } + guard let attachedRootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: node.id) else { + return true + } + return attachedRootID == frameRoot.id + } + guard candidates.count <= 1 else { + WebInspectorDataKitLog.debug( + "frame document projection ambiguous frameID=\(String(describing: frameRoot.frameID))" + ) + return nil + } + return candidates.first + } + + private func frameTargetID(for nodeID: DOMNode.ID) -> WebInspectorTarget.ID? { + nodeID.proxyID.targetScopeRawValue.map(WebInspectorTarget.ID.init) + } + + private func scopedFrameDocument(_ node: DOM.Node, to frameTargetID: WebInspectorTarget.ID) -> DOM.Node { + DOM.Node( + id: scopedNodeID(node.id, to: frameTargetID), + nodeType: node.nodeType, + nodeName: node.nodeName, + localName: node.localName, + nodeValue: node.nodeValue, + frameID: node.frameID, + documentURL: node.documentURL, + baseURL: node.baseURL, + attributes: node.attributes, + attributeList: node.attributeList, + childNodeCount: node.childNodeCount, + children: node.children?.map { scopedFrameDocument($0, to: frameTargetID) }, + contentDocument: node.contentDocument.map { scopedFrameDocument($0, to: frameTargetID) }, + shadowRoots: node.shadowRoots.map { scopedFrameDocument($0, to: frameTargetID) }, + templateContent: node.templateContent.map { scopedFrameDocument($0, to: frameTargetID) }, + beforePseudoElement: node.beforePseudoElement.map { scopedFrameDocument($0, to: frameTargetID) }, + otherPseudoElements: node.otherPseudoElements.map { scopedFrameDocument($0, to: frameTargetID) }, + afterPseudoElement: node.afterPseudoElement.map { scopedFrameDocument($0, to: frameTargetID) }, + pseudoType: node.pseudoType, + shadowRootType: node.shadowRootType + ) + } + + private func scopedNodeID(_ id: DOM.Node.ID, to frameTargetID: WebInspectorTarget.ID) -> DOM.Node.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return DOM.Node.ID(id.rawValue, scopedToTargetRawValue: frameTargetID.rawValue) + } + + private func resolvePendingInspectedNode( + requestSubtreeIfNeeded: Bool + ) -> Effects { + var effects = Effects() + guard let pendingInspectedNodeID else { + return effects + } + guard let inspectedNode = nodesByID[pendingInspectedNodeID], + isNodeAttachedToCurrentTree(inspectedNode) else { + if requestSubtreeIfNeeded { + requestFrameDocumentIfNeeded( + forNodeID: pendingInspectedNodeID, + reason: "DOM.inspect", + effects: &effects + ) + } + return effects + } + WebInspectorDataKitLog.debug( + "DOM.inspect resolved pending nodeID=\(String(describing: pendingInspectedNodeID))" + ) + self.pendingInspectedNodeID = nil + effects.merge(select(inspectedNode, reveal: .selectAndScroll)) + effects.inspectedNode = inspectedNode + return effects + } + + private func isNodeAttachedToCurrentTree(_ node: DOMNode) -> Bool { + guard let rootNode else { + return false + } + var visitedNodeIDs = Set() + return subtree(rootNode, contains: node.id, visitedNodeIDs: &visitedNodeIDs) + } + + private func subtree( + _ root: DOMNode, + contains nodeID: DOMNode.ID, + visitedNodeIDs: inout Set + ) -> Bool { + guard visitedNodeIDs.insert(root.id).inserted else { + return false + } + if root.id == nodeID { + return true + } + for associatedRoot in root.associatedSubtreeRoots() { + if subtree(associatedRoot, contains: nodeID, visitedNodeIDs: &visitedNodeIDs) { + return true + } + } + guard case let .loaded(children) = root.children else { + return false + } + for child in children { + if subtree(child, contains: nodeID, visitedNodeIDs: &visitedNodeIDs) { + return true + } + } + return false + } + + private func notifyDOMTreeSnapshot(reason: DOMTreeSnapshotReason) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applySnapshot(rootNode: rootNode, selectedNode: selectedNode, reason: reason) + } + } + + private func notifyDOMTreeChildrenReplaced(parent: DOMNode) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applyChildrenReplaced(parent: parent) + } + } + + private func notifyDOMTreeChildInserted( + parent: DOMNode, + node: DOMNode, + previousSiblingID: DOMNode.ID? + ) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applyChildInserted( + parent: parent, + node: node, + previousSiblingID: previousSiblingID + ) + } + } + + private func notifyDOMTreeChildRemoved(parent: DOMNode, nodeID: DOMNode.ID) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applyChildRemoved(parent: parent, nodeID: nodeID) + } + } + + private func notifyDOMTreeChildCountChanged(node: DOMNode) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applyChildCountChanged(node: node) + } + } + + private func notifyDOMTreeNodeChanged(_ node: DOMNode) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applyNodeChanged(node) + } + } + + private func notifyDOMTreeSelectionChanged( + _ node: DOMNode?, + reveal: DOMRevealPolicy = .selectAndScroll + ) { + pruneReleasedTreeStates() + for reference in treeStates { + reference.tree?.applySelectionChanged(nodeID: node?.id, reveal: reveal) + } + } + + private func pruneReleasedTreeStates() { + treeStates.removeAll { $0.tree == nil } + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } + + private func logDescription(_ id: DOM.Node.ID) -> String { + "\(id.unscopedRawValue)@\(id.targetScopeRawValue ?? "current-page")" + } + +} diff --git a/Sources/WebInspectorDataKit/Fetching.swift b/Sources/WebInspectorDataKit/Fetching.swift index 5aeb2189..f095db88 100644 --- a/Sources/WebInspectorDataKit/Fetching.swift +++ b/Sources/WebInspectorDataKit/Fetching.swift @@ -1,158 +1,13 @@ import Foundation import Observation -/// Value configuration for fetching DataKit model objects. -/// -/// A descriptor describes predicate, sort, limit, and offset behavior for -/// models such as ``NetworkRequest`` and ``ConsoleMessage``. -/// -/// Example: -/// -/// ```swift -/// let descriptor = WebInspectorFetchDescriptor( -/// predicate: #Predicate { request in -/// request.method == "POST" -/// }, -/// sortBy: [ -/// SortDescriptor(\.requestSentTimestamp, order: .reverse) -/// ], -/// fetchLimit: 100 -/// ) -/// -/// let results = context.network.fetchedResults(for: descriptor) -/// ``` -public struct WebInspectorFetchDescriptor: Sendable { - enum Kind: Hashable, Sendable { - case networkRequests - case consoleMessages - } - - let kind: Kind - /// Predicate used to filter fetched models. - public var predicate: Predicate? - - /// Sort descriptors used to order fetched models. - public var sortBy: [SortDescriptor] - - /// Maximum number of models to fetch. - public var fetchLimit: Int? { - didSet { - Self.validate(fetchLimit: fetchLimit) - } - } - /// Number of models to skip before returning results. - public var fetchOffset: Int { - didSet { - Self.validate(fetchOffset: fetchOffset) - } - } - - /// Creates a fetch descriptor. - public init( - predicate: Predicate? = nil, - sortBy: [SortDescriptor] = [], - fetchLimit: Int? = nil, - fetchOffset: Int = 0 - ) { - Self.validate(fetchLimit: fetchLimit) - Self.validate(fetchOffset: fetchOffset) - self.kind = Self.requireKnownKind() - self.predicate = predicate - self.sortBy = sortBy - self.fetchLimit = fetchLimit - self.fetchOffset = fetchOffset - } - - private static func requireKnownKind() -> Kind { - if Model.self == NetworkRequest.self { - return .networkRequests - } - if Model.self == ConsoleMessage.self { - return .consoleMessages - } - preconditionFailure("WebInspectorFetchDescriptor does not support fetching \(Model.self).") - } - - private static func validate(fetchLimit: Int?) { - if let fetchLimit { - precondition(fetchLimit >= 0, "WebInspectorFetchDescriptor fetchLimit must be non-negative.") - } - } - - private static func validate(fetchOffset: Int) { - precondition(fetchOffset >= 0, "WebInspectorFetchDescriptor fetchOffset must be non-negative.") - } - - var requiresRecordBackedQuery: Bool { - predicate != nil || sortBy.isEmpty == false || fetchLimit != nil || fetchOffset > 0 - } -} - -/// Mutable builder for a ``WebInspectorFetchDescriptor``. -public final class WebInspectorFetchRequest { - /// Predicate used to filter fetched models. - public var predicate: Predicate? - - /// Sort descriptors used to order fetched models. - public var sortDescriptors: [SortDescriptor] - - /// Maximum number of models to fetch. - public var fetchLimit: Int? { - didSet { - Self.validate(fetchLimit: fetchLimit) - } - } - /// Number of models to skip before returning results. - public var fetchOffset: Int { - didSet { - Self.validate(fetchOffset: fetchOffset) - } - } - - /// Creates a mutable fetch request. - public init( - predicate: Predicate? = nil, - sortDescriptors: [SortDescriptor] = [], - fetchLimit: Int? = nil, - fetchOffset: Int = 0 - ) { - Self.validate(fetchLimit: fetchLimit) - Self.validate(fetchOffset: fetchOffset) - self.predicate = predicate - self.sortDescriptors = sortDescriptors - self.fetchLimit = fetchLimit - self.fetchOffset = fetchOffset - } - - /// Immutable descriptor representing the request's current values. - public var fetchDescriptor: WebInspectorFetchDescriptor { - WebInspectorFetchDescriptor( - predicate: predicate, - sortBy: sortDescriptors, - fetchLimit: fetchLimit, - fetchOffset: fetchOffset - ) - } - - private static func validate(fetchLimit: Int?) { - if let fetchLimit { - precondition(fetchLimit >= 0, "WebInspectorFetchRequest fetchLimit must be non-negative.") - } - } - - private static func validate(fetchOffset: Int) { - precondition(fetchOffset >= 0, "WebInspectorFetchRequest fetchOffset must be non-negative.") - } -} - -final class WeakWebInspectorFetchedResults { +final class WeakWebInspectorFetchedResults { weak var value: WebInspectorFetchedResults? init(_ value: WebInspectorFetchedResults) { self.value = value } } - /// Stable identity for a fetched-results section. public struct WebInspectorFetchSectionID: RawRepresentable, Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral @@ -180,7 +35,8 @@ public struct WebInspectorFetchSectionID: RawRepresentable, Hashable, Sendable, } /// One fetched-results section and its models. -public struct WebInspectorFetchSection: Identifiable { +public struct WebInspectorFetchSection: Identifiable +where Model.ID: Hashable & Sendable { /// The stable section identity. public var id: WebInspectorFetchSectionID @@ -198,431 +54,377 @@ public struct WebInspectorFetchSection: Ident } } -enum WebInspectorSectionKey: Hashable, Sendable { - case networkMethod - case networkResourceType - case networkResourceCategory - case networkMIMEType - case consoleSource - case consoleLevel - case consoleKind - case consoleURL -} - -/// Descriptor for sectioning fetched results by a supported model key path. -public struct WebInspectorSectionDescriptor: Hashable, Sendable { - let key: WebInspectorSectionKey - - /// Creates a section descriptor from a non-optional string key path. - public init(_ keyPath: KeyPath) { - key = Self.requireKnownSectionKey(for: keyPath) - } - - /// Creates a section descriptor from an optional string key path. - public init(_ keyPath: KeyPath) { - key = Self.requireKnownSectionKey(for: keyPath) - } - - /// Creates a section descriptor from a raw-representable string value key path. - public init( - _ keyPath: KeyPath - ) where Value.RawValue == String { - key = Self.requireKnownSectionKey(for: keyPath) - } - - /// Creates a section descriptor from an optional raw-representable string value key path. - public init( - _ keyPath: KeyPath - ) where Value.RawValue == String { - key = Self.requireKnownSectionKey(for: keyPath) - } - - private static func requireKnownSectionKey(for keyPath: AnyKeyPath) -> WebInspectorSectionKey { - guard let key = WebInspectorKnownKeyPaths.sectionKey(for: Model.self, keyPath: keyPath) else { - preconditionFailure( - "WebInspectorSectionDescriptor does not support sectioning \(Model.self) by key path \(keyPath)." - ) - } - return key - } -} - -private enum WebInspectorKnownKeyPaths { - static func sectionKey( - for _: Model.Type, - keyPath: AnyKeyPath - ) -> WebInspectorSectionKey? { - if Model.self == NetworkRequest.self { - return networkSectionKey(keyPath) - } - if Model.self == ConsoleMessage.self { - return consoleSectionKey(keyPath) - } - return nil - } - - private static func networkSectionKey( - _ keyPath: AnyKeyPath - ) -> WebInspectorSectionKey? { - if keyPath == (\NetworkRequest.method as AnyKeyPath) { - return .networkMethod - } - if keyPath == (\NetworkRequest.resourceType as AnyKeyPath) { - return .networkResourceType - } - if keyPath == (\NetworkRequest.resourceCategory as AnyKeyPath) { - return .networkResourceCategory - } - if keyPath == (\NetworkRequest.mimeType as AnyKeyPath) { - return .networkMIMEType - } - return nil - } - - private static func consoleSectionKey( - _ keyPath: AnyKeyPath - ) -> WebInspectorSectionKey? { - if keyPath == (\ConsoleMessage.source as AnyKeyPath) { - return .consoleSource - } - if keyPath == (\ConsoleMessage.level as AnyKeyPath) { - return .consoleLevel - } - if keyPath == (\ConsoleMessage.kind as AnyKeyPath) { - return .consoleKind - } - if keyPath == (\ConsoleMessage.url as AnyKeyPath) { - return .consoleURL - } - return nil - } -} - -/// Observable collection of models produced by a fetch descriptor. +/// Observable results for one closed Network or Console query. @Observable -public final class WebInspectorFetchedResults { - /// The descriptor currently used by the results. - public private(set) var fetchDescriptor: WebInspectorFetchDescriptor - - /// The section descriptor currently used by the results. - public private(set) var sectionBy: WebInspectorSectionDescriptor? +public final class WebInspectorFetchedResults { + private struct State { + var items: [Model] + var sections: [WebInspectorFetchSection] + var modelsByID: [Model.ID: Model] + var snapshot: WebInspectorFetchedResultsSnapshot + var revision: UInt64 + var queryGeneration: UInt64? + var querySourceEpoch: UInt64? + var querySequence: UInt64 + } + + private var state: State + @ObservationIgnored private let updateBroker = + WebInspectorFetchedResultsUpdateBroker() + @ObservationIgnored weak var modelContext: WebInspectorModelContext? + @ObservationIgnored private var queryRegistrationID: WebInspectorQueryRegistrationID? + @ObservationIgnored private var queryRegistrationLifetime: WebInspectorQueryRegistrationLifetime? /// The fetched models in display order. - public private(set) var items: [Model] - - /// The fetched models grouped into display sections. - public private(set) var sections: [WebInspectorFetchSection] - package private(set) var topologyRevision: Int - - @ObservationIgnored private let transactionRelay = WebInspectorAsyncStreamRelay< - WebInspectorFetchedResultsTransaction - >() - @ObservationIgnored weak var modelContext: WebInspectorContext? - @ObservationIgnored private var networkQueryPlan: NetworkRequestQueryPlan? - @ObservationIgnored private var networkQueryState: NetworkRequestQueryState? - @ObservationIgnored private var networkResultSnapshot: WebInspectorFetchedResultsSnapshot? - - init( - fetchDescriptor: WebInspectorFetchDescriptor, - sectionBy: WebInspectorSectionDescriptor? = nil, - items: [Model] = [], - modelContext: WebInspectorContext? = nil - ) { - self.fetchDescriptor = fetchDescriptor - self.sectionBy = sectionBy - self.items = items - sections = Self.sections(for: items, sectionBy: sectionBy) - self.modelContext = modelContext - topologyRevision = 0 - networkQueryPlan = nil - networkQueryState = nil - networkResultSnapshot = nil + public var items: [Model] { + modelContext?.preconditionOwnerIsolation() + return state.items } - deinit { - transactionRelay.finish() - } - - func makeTransactionStream() -> AsyncStream> { - transactionRelay.makeStream() + /// The fetched models grouped into display sections. + public var sections: [WebInspectorFetchSection] { + modelContext?.preconditionOwnerIsolation() + return state.sections } - func setItems(_ items: [Model], updatedItemIDs: Set = []) { - let oldSnapshot = currentSnapshot - self.items = items - sections = Self.sections(for: items, sectionBy: sectionBy) - bumpTopologyRevisionIfNeeded(oldSnapshot: oldSnapshot) - yieldTransaction(oldSnapshot: oldSnapshot, updatedItemIDs: updatedItemIDs) + /// The complete current section and item identity snapshot. + public var snapshot: WebInspectorFetchedResultsSnapshot { + modelContext?.preconditionOwnerIsolation() + return state.snapshot } - func insertItem(_ item: Model) { - precondition(items.contains { $0.id == item.id } == false, "WebInspectorFetchedResults cannot insert a duplicate item ID.") - let oldSnapshot = currentSnapshot - items.append(item) - sections = Self.sections(for: items, sectionBy: sectionBy) - bumpTopologyRevisionIfNeeded(oldSnapshot: oldSnapshot) - yieldTransaction(oldSnapshot: oldSnapshot, updatedItemIDs: []) + /// Monotonically increasing publication revision. + public var revision: UInt64 { + modelContext?.preconditionOwnerIsolation() + return state.revision } - func refreshAfterItemMutation(_ item: Model) { - guard items.contains(where: { $0.id == item.id }) else { - return - } - let oldSnapshot = currentSnapshot - if sectionBy != nil { - sections = Self.sections(for: items, sectionBy: sectionBy) - } - bumpTopologyRevisionIfNeeded(oldSnapshot: oldSnapshot) - yieldTransaction(oldSnapshot: oldSnapshot, updatedItemIDs: [item.id]) - } - - func resetItems(_ items: [Model]) { - let oldSnapshot = currentSnapshot - self.items = items - sections = Self.sections(for: items, sectionBy: sectionBy) - bumpTopologyRevision() - yieldResetTransaction(oldSnapshot: oldSnapshot) + init(modelContext: WebInspectorModelContext) { + state = State( + items: [], + sections: [], + modelsByID: [:], + snapshot: WebInspectorFetchedResultsSnapshot(), + revision: 0, + queryGeneration: nil, + querySourceEpoch: nil, + querySequence: 0 + ) + self.modelContext = modelContext + queryRegistrationID = nil + queryRegistrationLifetime = nil } - func applyFetchDescriptor(_ descriptor: WebInspectorFetchDescriptor, items: [Model]) { - fetchDescriptor = descriptor - resetItems(items) + deinit { + updateBroker.finish() } - /// Replaces the fetch descriptor and updates the result contents. - public func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - isolation: isolated (any Actor) = #isolation + func installQueryRegistration( + id: WebInspectorQueryRegistrationID, + lifetime: WebInspectorQueryRegistrationLifetime ) { - guard let modelContext else { - preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorContext.") - } - modelContext.updateFetchDescriptor(descriptor, for: self, isolation: isolation) + precondition( + queryRegistrationID == nil && queryRegistrationLifetime == nil, + "WebInspectorFetchedResults already owns a query registration." + ) + queryRegistrationID = id + queryRegistrationLifetime = lifetime } - private var currentSnapshot: WebInspectorFetchedResultsSnapshot { - WebInspectorFetchedResultsSnapshot(sections: sections) + var concreteQueryRegistrationID: WebInspectorQueryRegistrationID? { + queryRegistrationID } - private func bumpTopologyRevisionIfNeeded(oldSnapshot: WebInspectorFetchedResultsSnapshot) { - guard oldSnapshot != currentSnapshot else { - return + func nextConcreteQueryGeneration() -> UInt64 { + guard let queryRegistrationLifetime else { + preconditionFailure("WebInspectorFetchedResults has no concrete query registration lifetime.") } - bumpTopologyRevision() + return queryRegistrationLifetime.nextGeneration() } - private func bumpTopologyRevision() { - topologyRevision &+= 1 + func isCurrentConcreteQueryGeneration(_ generation: UInt64) -> Bool { + queryRegistrationLifetime?.isCurrent(generation: generation) == true } - private func yieldTransaction( - oldSnapshot: WebInspectorFetchedResultsSnapshot, - updatedItemIDs: Set + private func installInitialConcreteState( + generation: UInt64, + projection: WebInspectorIndexedQueryProjection, + lookup: (Model.ID) -> Model? ) { - guard transactionRelay.hasContinuations else { - return - } - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: currentSnapshot, - updatedItemIDs: updatedItemIDs + precondition( + state.queryGeneration == nil, + "A concrete fetched-results initial state can only be installed once." + ) + let resolved = resolve(projection.snapshot, reusing: [:], lookup: lookup) + state = State( + items: resolved.items, + sections: resolved.sections, + modelsByID: resolved.modelsByID, + snapshot: projection.snapshot, + revision: 0, + queryGeneration: generation, + querySourceEpoch: projection.sourceEpoch, + querySequence: projection.sequence ) - guard transaction.hasChanges else { - return - } - transactionRelay.yield(transaction) } - private func yieldResetTransaction( - oldSnapshot: WebInspectorFetchedResultsSnapshot - ) { - guard transactionRelay.hasContinuations else { - return + @discardableResult + private func applyConcreteProjection( + generation: UInt64, + projection: WebInspectorIndexedQueryProjection, + isReplacement: Bool, + lookup: (Model.ID) -> Model? + ) -> Bool { + guard let currentGeneration = state.queryGeneration, + let currentSourceEpoch = state.querySourceEpoch else { + preconditionFailure("A concrete fetched-results projection requires an installed initial state.") + } + guard projection.sourceEpoch >= currentSourceEpoch, + generation >= currentGeneration else { + return false + } + if generation == currentGeneration { + if projection.sourceEpoch == currentSourceEpoch, + projection.sequence <= state.querySequence { + return false + } + } else { + precondition( + isReplacement, + "A newer concrete query generation must publish as one replacement." + ) } - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: currentSnapshot, - isReset: true, - itemChanges: [] + + let resetsSource = projection.sourceEpoch != currentSourceEpoch + let shouldReset = isReplacement || resetsSource + let resolved = resolve( + projection.snapshot, + reusing: resetsSource ? [:] : state.modelsByID, + lookup: lookup ) - transactionRelay.yield(transaction) + let visibleReconfigureIDs = projection.reconfigureItemIDs.intersection( + projection.snapshot.itemIDs + ) + if shouldReset == false, + state.snapshot == projection.snapshot, + visibleReconfigureIDs.isEmpty { + state.querySourceEpoch = projection.sourceEpoch + state.querySequence = projection.sequence + return true + } + publish( + items: resolved.items, + sections: resolved.sections, + modelsByID: resolved.modelsByID, + snapshot: projection.snapshot, + queryGeneration: generation, + querySourceEpoch: projection.sourceEpoch, + querySequence: projection.sequence, + isReset: shouldReset, + updatedItemIDs: visibleReconfigureIDs + ) + return true } - private static func sections( - for items: [Model], - sectionBy: WebInspectorSectionDescriptor? - ) -> [WebInspectorFetchSection] { - guard items.isEmpty == false else { - return [] - } - guard let sectionBy else { - return [ - WebInspectorFetchSection( - id: .defaultSection, - title: nil, - items: items - ) - ] + private func resolve( + _ snapshot: WebInspectorFetchedResultsSnapshot, + reusing existingModelsByID: [Model.ID: Model], + lookup: (Model.ID) -> Model? + ) -> ( + items: [Model], + sections: [WebInspectorFetchSection], + modelsByID: [Model.ID: Model] + ) { + var modelsByID: [Model.ID: Model] = [:] + modelsByID.reserveCapacity(snapshot.itemIDs.count) + for id in snapshot.itemIDs { + if let existing = existingModelsByID[id] { + modelsByID[id] = existing + continue + } + guard let model = lookup(id) else { + preconditionFailure("A concrete fetched-results snapshot referenced an unregistered \(Model.self).") + } + modelsByID[id] = model } - - var sections: [(id: WebInspectorFetchSectionID, title: String?, items: [Model])] = [] - for item in items { - let section = sectionIdentity(for: item, sectionBy: sectionBy) - if let index = sections.firstIndex(where: { $0.id == section.id }) { - sections[index].items.append(item) - } else { - sections.append((id: section.id, title: section.title, items: [item])) + let items = snapshot.itemIDs.map { id in + guard let model = modelsByID[id] else { + preconditionFailure("A concrete fetched-results identity map lost a visible \(Model.self).") } + return model } - return sections.map { - WebInspectorFetchSection(id: $0.id, title: $0.title, items: $0.items) + let sections = snapshot.sections.map { section in + WebInspectorFetchSection( + id: section.id, + title: section.title, + items: section.itemIDs.map { id in + guard let model = modelsByID[id] else { + preconditionFailure("A concrete fetched-results section lost a visible \(Model.self).") + } + return model + } + ) + } + return (items, sections, modelsByID) + } + + /// Returns an atomic bounded stream beginning with the current result state. + /// + /// The first consumed element is always `.initial`; it advances to the + /// newest complete state if publications arrive before consumption. The + /// stream then retains only its newest unconsumed transaction. Every + /// transaction includes a full current snapshot, so consumers recover from + /// a revision gap by replacing their local snapshot. + public func updates() -> AsyncStream> { + modelContext?.preconditionOwnerIsolation() + return updateBroker.makeStream(initial: .initial( + revision: state.revision, + snapshot: state.snapshot + )) + } + + private func publish( + items: [Model], + sections: [WebInspectorFetchSection], + modelsByID: [Model.ID: Model], + snapshot: WebInspectorFetchedResultsSnapshot, + queryGeneration: UInt64, + querySourceEpoch: UInt64, + querySequence: UInt64, + isReset: Bool, + updatedItemIDs: Set + ) { + let oldState = state + let reconfigureItemIDs = updatedItemIDs.intersection(snapshot.itemIDs) + guard isReset + || oldState.snapshot != snapshot + || reconfigureItemIDs.isEmpty == false else { + return } - } - private static func sectionIdentity( - for item: Model, - sectionBy: WebInspectorSectionDescriptor - ) -> (id: WebInspectorFetchSectionID, title: String?) { - let value: String? - switch sectionBy.key { - case .networkMethod: - value = (item as? NetworkRequest)?.method - case .networkResourceType: - value = (item as? NetworkRequest)?.resourceType?.rawValue - case .networkResourceCategory: - value = (item as? NetworkRequest)?.resourceCategory.rawValue - case .networkMIMEType: - value = (item as? NetworkRequest)?.mimeType - case .consoleSource: - value = (item as? ConsoleMessage)?.source.rawValue - case .consoleLevel: - value = (item as? ConsoleMessage)?.level.rawValue - case .consoleKind: - value = (item as? ConsoleMessage)?.kind?.rawValue - case .consoleURL: - value = (item as? ConsoleMessage)?.url + let transaction: WebInspectorFetchedResultsTransaction + if isReset { + transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldState.snapshot, + newSnapshot: snapshot, + isReset: true, + itemChanges: [] + ) + } else { + transaction = WebInspectorFetchedResultsTransaction( + oldSnapshot: oldState.snapshot, + newSnapshot: snapshot, + updatedItemIDs: reconfigureItemIDs + ) } - let title = value ?? "" - return (WebInspectorFetchSectionID(rawValue: title), title) + precondition( + oldState.revision < UInt64.max, + "WebInspectorFetchedResults publication revision overflowed." + ) + let revision = oldState.revision + 1 + state = State( + items: items, + sections: sections, + modelsByID: modelsByID, + snapshot: snapshot, + revision: revision, + queryGeneration: queryGeneration, + querySourceEpoch: querySourceEpoch, + querySequence: querySequence + ) + updateBroker.yield(.transaction( + revision: revision, + transaction: transaction, + reconfigureItemIDs: reconfigureItemIDs + )) } } extension WebInspectorFetchedResults where Model == NetworkRequest { - var networkSnapshotForDelta: WebInspectorFetchedResultsSnapshot { - if let networkResultSnapshot { - return networkResultSnapshot - } - let snapshot = WebInspectorFetchedResultsSnapshot(sections: sections) - networkResultSnapshot = snapshot - return snapshot - } - - func currentNetworkQueryPlan(context: WebInspectorContext) -> NetworkRequestQueryPlan { - if let networkQueryPlan { - return networkQueryPlan - } - let plan = NetworkRequestQueryPlan(descriptor: fetchDescriptor, context: context) - networkQueryPlan = plan - return plan - } - - func setNetworkItems( - _ requests: [NetworkRequest], - plan: NetworkRequestQueryPlan, + func installInitialNetworkQuery( + _ query: NetworkQuery, + generation: UInt64, + projection: NetworkRequestIndex.QueryProjection, lookup: (NetworkRequest.ID) -> NetworkRequest? ) { - networkQueryPlan = plan - if plan.requiresQuery { - let state = NetworkRequestQueryState(plan: plan, requests: requests) - networkQueryState = state - setItems(state.visibleRequests(lookup: lookup)) - } else { - networkQueryState = nil - setItems(requests) - } - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + installInitialConcreteState( + generation: generation, + projection: projection, + lookup: lookup + ) } - func applyNetworkFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - plan: NetworkRequestQueryPlan, - requests: [NetworkRequest], + @discardableResult + func applyNetworkQueryProjection( + _ projection: NetworkRequestIndex.QueryProjection, + query: NetworkQuery, + generation: UInt64, + isReplacement: Bool, lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - fetchDescriptor = descriptor - networkQueryPlan = plan - if plan.requiresQuery { - let state = NetworkRequestQueryState(plan: plan, requests: requests) - networkQueryState = state - resetItems(state.visibleRequests(lookup: lookup)) - } else { - networkQueryState = nil - resetItems(requests) - } - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + ) -> Bool { + applyConcreteProjection( + generation: generation, + projection: projection, + isReplacement: isReplacement, + lookup: lookup + ) } - func resetNetworkItems() { - if let state = networkQueryState { - networkQueryState = NetworkRequestQueryState(plan: state.plan, requests: []) + /// Replaces this result's Network query atomically. + public nonisolated(nonsending) func update(_ query: NetworkQuery) async throws { + guard queryRegistrationID != nil, state.queryGeneration != nil else { + preconditionFailure( + "A Network query can only update results created by networkRequests(matching:)." + ) } - resetItems([]) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot() + guard let modelContext else { + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorModelContext.") + } + try await modelContext.updateNetworkQuery(query, for: self) } +} - func insertNetworkRequest( - _ request: NetworkRequest, - lookup: (NetworkRequest.ID) -> NetworkRequest? +extension WebInspectorFetchedResults where Model == ConsoleMessage { + func installInitialConsoleQuery( + _ query: ConsoleQuery, + generation: UInt64, + projection: ConsoleMessageIndex.QueryProjection, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? ) { - guard var state = networkQueryState else { - insertItem(request) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) - return - } - state.upsert(request: request) - networkQueryState = state - setItems(state.visibleRequests(lookup: lookup)) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + installInitialConcreteState( + generation: generation, + projection: projection, + lookup: lookup + ) } - func refreshNetworkRequestAfterMutation( - _ request: NetworkRequest, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - guard var state = networkQueryState else { - refreshAfterItemMutation(request) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) - return - } - state.upsert(request: request) - networkQueryState = state - setItems(state.visibleRequests(lookup: lookup), updatedItemIDs: [request.id]) - networkResultSnapshot = WebInspectorFetchedResultsSnapshot(sections: sections) + @discardableResult + func applyConsoleQueryProjection( + _ projection: ConsoleMessageIndex.QueryProjection, + query: ConsoleQuery, + generation: UInt64, + isReplacement: Bool, + lookup: (ConsoleMessage.ID) -> ConsoleMessage? + ) -> Bool { + applyConcreteProjection( + generation: generation, + projection: projection, + isReplacement: isReplacement, + lookup: lookup + ) } - func applyNetworkDelta( - _ delta: NetworkResultSetDelta, - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) { - let oldSnapshot = networkSnapshotForDelta - items = delta.snapshot.itemIDs.compactMap(lookup) - sections = delta.snapshot.sections.map { section in - WebInspectorFetchSection( - id: section.id, - title: section.title, - items: section.itemIDs.compactMap(lookup) + /// Replaces this result's Console query atomically. + public nonisolated(nonsending) func update(_ query: ConsoleQuery) async throws { + guard queryRegistrationID != nil, state.queryGeneration != nil else { + preconditionFailure( + "A Console query can only update results created by consoleMessages(matching:)." ) } - networkResultSnapshot = delta.snapshot - if oldSnapshot != delta.snapshot { - bumpTopologyRevision() - } - guard transactionRelay.hasContinuations, - let transaction = delta.transaction, - transaction.hasChanges else { - return + guard let modelContext else { + preconditionFailure("WebInspectorFetchedResults is not registered in a WebInspectorModelContext.") } - transactionRelay.yield(transaction) + try await modelContext.updateConsoleQuery(query, for: self) } } diff --git a/Sources/WebInspectorDataKit/NetworkQuery.swift b/Sources/WebInspectorDataKit/NetworkQuery.swift new file mode 100644 index 00000000..99e49e89 --- /dev/null +++ b/Sources/WebInspectorDataKit/NetworkQuery.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A closed query for live Network request results. +public struct NetworkQuery: Sendable, Equatable { + /// Text matched against the request's searchable Network fields. + /// + /// Leading and trailing whitespace is removed. Empty and whitespace-only + /// values are normalized to `nil`. + public var search: String? { + didSet { + search = Self.normalize(search) + } + } + + /// Resource categories to include, or an empty set to include every category. + public var resourceCategories: Set + + /// HTTP methods to include, or an empty set to include every method. + public var methods: Set + + /// The ordering applied before offset and limit. + public var sort: NetworkSort + + /// The optional grouping applied to visible requests. + public var section: NetworkSection? + + /// The number of matching requests skipped before publication. + public var offset: Int { + didSet { + Self.validate(offset: offset) + } + } + + /// The maximum number of requests published after the offset, or `nil` for no limit. + public var limit: Int? { + didSet { + Self.validate(limit: limit) + } + } + + /// Creates a Network request query. + public init( + search: String? = nil, + resourceCategories: Set = [], + methods: Set = [], + sort: NetworkSort = .requestTimeDescending, + section: NetworkSection? = nil, + offset: Int = 0, + limit: Int? = nil + ) { + Self.validate(offset: offset) + Self.validate(limit: limit) + self.search = Self.normalize(search) + self.resourceCategories = resourceCategories + self.methods = methods + self.sort = sort + self.section = section + self.offset = offset + self.limit = limit + } + + private static func normalize(_ search: String?) -> String? { + guard let normalized = search?.trimmingCharacters(in: .whitespacesAndNewlines), + normalized.isEmpty == false else { + return nil + } + return normalized + } + + private static func validate(offset: Int) { + precondition(offset >= 0, "NetworkQuery offset must be non-negative.") + } + + private static func validate(limit: Int?) { + if let limit { + precondition(limit >= 0, "NetworkQuery limit must be non-negative.") + } + } +} + +/// Supported Network request ordering. +public enum NetworkSort: Sendable, Equatable { + /// Orders requests from the earliest request time to the latest. + case requestTimeAscending + + /// Orders requests from the latest request time to the earliest. + case requestTimeDescending +} + +/// Supported Network request grouping. +public enum NetworkSection: Sendable, Equatable { + /// Groups visible requests by HTTP method. + case method +} diff --git a/Sources/WebInspectorDataKit/NetworkRequest.swift b/Sources/WebInspectorDataKit/NetworkRequest.swift index 5dfff111..e687bdf2 100644 --- a/Sources/WebInspectorDataKit/NetworkRequest.swift +++ b/Sources/WebInspectorDataKit/NetworkRequest.swift @@ -275,6 +275,25 @@ public final class WebSocketState { /// Observable request or response body state for a network request. @Observable public final class NetworkBody { + final class ResponseFetchIdentity {} + + struct ResponseFetchLease { + fileprivate let identity: ResponseFetchIdentity + let completion: ReplyPromise + } + + enum ResponseFetchAcquisition { + case loaded + case failed(WebInspectorProxyError) + case owner(ResponseFetchLease) + case waiter(ResponseFetchLease) + } + + private struct ResponseFetch { + let lease: ResponseFetchLease + var task: Task? + } + /// The body side represented by the model. public enum Role: CaseIterable, Hashable, Sendable { /// A request body. @@ -395,6 +414,7 @@ public final class NetworkBody { public private(set) var textRepresentationSyntaxKind: SyntaxKind @ObservationIgnored private var isBatchingTextRepresentationInvalidation: Bool @ObservationIgnored private var needsTextRepresentationInvalidation: Bool + @ObservationIgnored private var responseFetch: ResponseFetch? package init( role: Role = .response, @@ -419,9 +439,17 @@ public final class NetworkBody { textRepresentationSyntaxKind = .plainText isBatchingTextRepresentationInvalidation = false needsTextRepresentationInvalidation = false + responseFetch = nil refreshTextRepresentation() } + deinit { + responseFetch?.lease.completion.fulfill( + .failure(WebInspectorProxyError.staleIdentifier) + ) + responseFetch?.task?.cancel() + } + var needsFetch: Bool { switch phase { case .available: @@ -438,8 +466,78 @@ public final class NetworkBody { } } - func markFetching() { - phase = .fetching + func acquireResponseFetch() -> ResponseFetchAcquisition { + switch phase { + case .loaded: + return .loaded + case let .failed(error): + return .failed(error) + case .fetching: + guard let responseFetch else { + preconditionFailure("A fetching NetworkBody has no response-fetch owner.") + } + return .waiter(responseFetch.lease) + case .available: + precondition( + responseFetch == nil, + "An available NetworkBody cannot already own a response fetch." + ) + let lease = ResponseFetchLease( + identity: ResponseFetchIdentity(), + completion: ReplyPromise() + ) + responseFetch = ResponseFetch(lease: lease, task: nil) + phase = .fetching + return .owner(lease) + } + } + + func installResponseFetchTask( + _ task: Task, + for lease: ResponseFetchLease + ) { + guard var responseFetch, + responseFetch.lease.identity === lease.identity else { + task.cancel() + return + } + precondition( + responseFetch.task == nil, + "A NetworkBody response fetch can install only one task." + ) + responseFetch.task = task + self.responseFetch = responseFetch + } + + func finishResponseFetch( + _ result: Result, + for lease: ResponseFetchLease + ) { + guard let responseFetch, + responseFetch.lease.identity === lease.identity else { + return + } + self.responseFetch = nil + switch result { + case let .success(body): + load(body) + lease.completion.fulfill(.success(body)) + case let .failure(error): + fail(error) + lease.completion.fulfill(.failure(error)) + } + } + + func invalidateResponseFetch( + with error: WebInspectorProxyError = .staleIdentifier + ) { + guard let responseFetch else { + return + } + self.responseFetch = nil + fail(error) + responseFetch.lease.completion.fulfill(.failure(error)) + responseFetch.task?.cancel() } func load(_ body: Network.Body) { @@ -644,7 +742,7 @@ public final class NetworkBody { /// Observable model for one network request. @Observable -public final class NetworkRequest: WebInspectorFetchableModel { +public final class NetworkRequest: WebInspectorPersistentModel { /// Stable identity for a network request within a context. public struct ID: Hashable, Sendable { let proxyID: Network.Request.ID @@ -769,9 +867,16 @@ public final class NetworkRequest: WebInspectorFetchableModel { public private(set) var requestBody: NetworkBody? /// Response body state. - public private(set) var responseBody: NetworkBody + public private(set) var responseBody: NetworkBody { + willSet { + guard responseBody !== newValue else { + return + } + responseBody.invalidateResponseFetch() + } + } - @ObservationIgnored weak var modelContext: WebInspectorContext? + @ObservationIgnored weak var modelContext: WebInspectorModelContext? @ObservationIgnored private var currentRequest: Network.Request var proxyID: Network.Request.ID { @@ -797,7 +902,7 @@ public final class NetworkRequest: WebInspectorFetchableModel { request: Network.Request, resourceType: Network.ResourceType?, timestamp: Double?, - modelContext: WebInspectorContext + modelContext: WebInspectorModelContext ) { id = ID(request.id) url = request.url @@ -883,20 +988,6 @@ public final class NetworkRequest: WebInspectorFetchableModel { status } - /// Fetches the response body when it is available and not already loaded. - public func fetchResponseBody(isolation: isolated (any Actor) = #isolation) async { - guard canFetchResponseBody else { - return - } - let expectedBody = responseBody - expectedBody.markFetching() - guard let modelContext else { - expectedBody.fail(.disconnected("NetworkRequest is not registered in a WebInspectorContext.")) - return - } - await modelContext.fetchResponseBody(for: self, expectedBody: expectedBody, isolation: isolation) - } - func applyRequestWillBeSent( request: Network.Request, resourceType: Network.ResourceType?, @@ -1057,16 +1148,8 @@ public final class NetworkRequest: WebInspectorFetchableModel { state = .finished } - func finishResponseBodyFetch(result: Result, expectedBody: NetworkBody) { - guard responseBody === expectedBody else { - return - } - switch result { - case let .success(body): - expectedBody.load(body) - case let .failure(error): - expectedBody.fail(error) - } + func invalidateResponseBodyFetch() { + responseBody.invalidateResponseFetch() } func applyWebSocketCreated(url: String) { diff --git a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift index 8c553203..3e6b507b 100644 --- a/Sources/WebInspectorDataKit/NetworkRequestIndex.swift +++ b/Sources/WebInspectorDataKit/NetworkRequestIndex.swift @@ -1,403 +1,568 @@ import Foundation -package struct NetworkResultSetDelta: Sendable { - package var snapshot: WebInspectorFetchedResultsSnapshot - package var transaction: WebInspectorFetchedResultsTransaction? -} - package actor NetworkRequestIndex { + package typealias QueryProjection = WebInspectorIndexedQueryProjection + package typealias QueryDelivery = WebInspectorIndexedQueryDelivery + + private enum Mutation { + case replace([NetworkRequestRecordInput], sourceEpoch: UInt64?) + case upsert(NetworkRequestRecordInput) + } + + private struct PendingMutation { + var mutation: Mutation + var continuation: CheckedContinuation<[QueryDelivery], Never> + } + + private struct SequenceWaiter { + var minimumSequence: UInt64 + var continuation: CheckedContinuation + } + + private final class WeakLifetime { + weak var value: WebInspectorQueryRegistrationLifetime? + + init(_ value: WebInspectorQueryRegistrationLifetime) { + self.value = value + } + } + + private struct QueryVersion { + var generation: UInt64 + var query: NetworkQuery + var matchingIDs: [NetworkRequest.ID] + var snapshot: WebInspectorFetchedResultsSnapshot + var sequence: UInt64 + var acknowledgedSequence: UInt64 + } + + private struct QueryRegistration { + var lifetime: WeakLifetime + var active: QueryVersion + var candidate: QueryVersion? + } + private var recordsByID: [NetworkRequest.ID: NetworkRequestRecord] = [:] private var orderedIDs: [NetworkRequest.ID] = [] + private var lastUpdatedSequenceByID: [NetworkRequest.ID: UInt64] = [:] private var lastAppliedSequence: UInt64 = 0 + private var sourceEpoch: UInt64 = 0 + private var pendingMutationsBySequence: [UInt64: PendingMutation] = [:] + private var sequenceWaiters: [UInt64: SequenceWaiter] = [:] + private var nextSequenceWaiterID: UInt64 = 0 + private var queryRegistrations: [WebInspectorQueryRegistrationID: QueryRegistration] = [:] package init() {} - package func replace(with inputs: [NetworkRequestRecordInput], sequence: UInt64) { - guard apply(sequence: sequence) else { - return - } - recordsByID = [:] - recordsByID.reserveCapacity(inputs.count) - orderedIDs = [] - orderedIDs.reserveCapacity(inputs.count) - for input in inputs { - upsertRecord(input) + @discardableResult + package func replace( + with inputs: [NetworkRequestRecordInput], + sequence: UInt64, + sourceEpoch: UInt64? = nil + ) async -> [QueryDelivery] { + await enqueue(.replace(inputs, sourceEpoch: sourceEpoch), sequence: sequence) + } + + @discardableResult + package func upsert( + _ input: NetworkRequestRecordInput, + sequence: UInt64 + ) async -> [QueryDelivery] { + await enqueue(.upsert(input), sequence: sequence) + } + + private func enqueue(_ mutation: Mutation, sequence: UInt64) async -> [QueryDelivery] { + precondition( + sequence > lastAppliedSequence, + "NetworkRequestIndex received an already-applied mutation sequence." + ) + precondition( + pendingMutationsBySequence[sequence] == nil, + "NetworkRequestIndex received a duplicate mutation sequence." + ) + return await withCheckedContinuation { continuation in + pendingMutationsBySequence[sequence] = PendingMutation( + mutation: mutation, + continuation: continuation + ) + drainContiguousMutations() } } - package func upsert(_ input: NetworkRequestRecordInput, sequence: UInt64) { - guard apply(sequence: sequence) else { - return + private func drainContiguousMutations() { + while lastAppliedSequence < UInt64.max { + let sequence = lastAppliedSequence + 1 + guard let pending = pendingMutationsBySequence.removeValue(forKey: sequence) else { + resumeSequenceWaiters() + return + } + let deliveries = apply(pending.mutation, sequence: sequence) + lastAppliedSequence = sequence + pending.continuation.resume(returning: deliveries) } - upsertRecord(input) + precondition( + pendingMutationsBySequence.isEmpty, + "NetworkRequestIndex mutation sequence overflowed." + ) + resumeSequenceWaiters() } - private func apply(sequence: UInt64) -> Bool { - guard sequence > lastAppliedSequence else { - return false + private func apply(_ mutation: Mutation, sequence: UInt64) -> [QueryDelivery] { + switch mutation { + case let .replace(inputs, replacementSourceEpoch): + if let replacementSourceEpoch { + precondition( + replacementSourceEpoch >= sourceEpoch, + "NetworkRequestIndex source epochs must not move backwards." + ) + sourceEpoch = replacementSourceEpoch + } + recordsByID = [:] + recordsByID.reserveCapacity(inputs.count) + orderedIDs = [] + orderedIDs.reserveCapacity(inputs.count) + lastUpdatedSequenceByID = [:] + lastUpdatedSequenceByID.reserveCapacity(inputs.count) + for input in inputs { + upsertRecord(input, sequence: sequence) + } + return updateAllQueryRegistrations(sequence: sequence, rebuilding: true) + case let .upsert(input): + upsertRecord(input, sequence: sequence) + return updateAllQueryRegistrations( + sequence: sequence, + rebuilding: false, + changedID: input.id + ) } - lastAppliedSequence = sequence - return true } - private func upsertRecord(_ input: NetworkRequestRecordInput) { + private func upsertRecord(_ input: NetworkRequestRecordInput, sequence: UInt64) { let isNewRecord = recordsByID[input.id] == nil let record = NetworkRequestRecord(input: input) recordsByID[record.id] = record + lastUpdatedSequenceByID[record.id] = sequence if isNewRecord { orderedIDs.append(record.id) } } - package func delta( - plan: NetworkRequestQueryPlan, - sectionBy: WebInspectorSectionDescriptor?, - oldSnapshot: WebInspectorFetchedResultsSnapshot, - changedID: NetworkRequest.ID? - ) -> NetworkResultSetDelta? { - guard plan.requiresModelPredicate == false else { - return nil + package func register( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: NetworkQuery, + lifetime: WebInspectorQueryRegistrationLifetime, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + try await waitUntilApplied(minimumSequence) + try Task.checkCancellation() + pruneQueryRegistrations() + guard lifetime.isCurrent(generation: generation) else { + throw CancellationError() } - let newSnapshot = snapshot(plan: plan, sectionBy: sectionBy) - guard oldSnapshot != newSnapshot else { - return nil + precondition( + queryRegistrations[id] == nil, + "NetworkRequestIndex received a duplicate query registration ID." + ) + let version = makeQueryVersion( + generation: generation, + query: query, + acknowledgedSequence: lastAppliedSequence + ) + try Task.checkCancellation() + guard lifetime.isCurrent(generation: generation) else { + throw CancellationError() } - let transaction = NetworkResultSetTransactionBuilder.transaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - changedID: changedID + queryRegistrations[id] = QueryRegistration( + lifetime: WeakLifetime(lifetime), + active: version, + candidate: nil ) - return NetworkResultSetDelta(snapshot: newSnapshot, transaction: transaction) + return projection(for: version) } - private func snapshot( - plan: NetworkRequestQueryPlan, - sectionBy: WebInspectorSectionDescriptor? - ) -> WebInspectorFetchedResultsSnapshot { - let matchingRecords = visibleRecords(plan: plan) - guard matchingRecords.isEmpty == false else { - return WebInspectorFetchedResultsSnapshot() + package func prepareReplacement( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: NetworkQuery, + minimumSequence: UInt64 + ) async throws -> QueryProjection { + try await waitUntilApplied(minimumSequence) + try Task.checkCancellation() + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.lifetime.value?.isCurrent(generation: generation) == true, + generation > registration.active.generation, + generation > (registration.candidate?.generation ?? 0) else { + throw CancellationError() } - guard let sectionBy else { - return WebInspectorFetchedResultsSnapshot(itemIDs: matchingRecords.map(\.id)) + let candidate = makeQueryVersion( + generation: generation, + query: query, + acknowledgedSequence: lastAppliedSequence + ) + try Task.checkCancellation() + guard registration.lifetime.value?.isCurrent(generation: generation) == true else { + throw CancellationError() } + registration.candidate = candidate + queryRegistrations[id] = registration + return projection(for: candidate) + } - var sections: [( - id: WebInspectorFetchSectionID, - title: String?, - itemIDs: [NetworkRequest.ID] - )] = [] - for record in matchingRecords { - let identity = sectionIdentity(for: record, sectionBy: sectionBy) - if let index = sections.firstIndex(where: { $0.id == identity.id }) { - sections[index].itemIDs.append(record.id) - } else { - sections.append(( - id: identity.id, - title: identity.title, - itemIDs: [record.id] - )) - } + package func commitReplacement( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) -> QueryProjection? { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.lifetime.value?.isCurrent(generation: generation) == true, + var candidate = registration.candidate, + candidate.generation == generation else { + return nil } - return WebInspectorFetchedResultsSnapshot(sections: sections.map { section in - WebInspectorFetchedResultsSnapshot.Section( - id: section.id, - title: section.title, - itemIDs: section.itemIDs - ) - }) + candidate.acknowledgedSequence = candidate.sequence + registration.active = candidate + registration.candidate = nil + queryRegistrations[id] = registration + return projection(for: candidate) } - private func visibleRecords(plan: NetworkRequestQueryPlan) -> [NetworkRequestRecord] { - var records: [NetworkRequestRecord] = [] - records.reserveCapacity(orderedIDs.count) - for id in orderedIDs { - guard let record = recordsByID[id] else { - continue - } - guard plan.matches(record: record) == true else { - continue - } - records.append(record) + package func discardCandidate( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.candidate?.generation == generation else { + return } + registration.candidate = nil + queryRegistrations[id] = registration + } - if plan.sortComparators.isEmpty == false { - records.sort { lhs, rhs in - plan.ordersBefore(lhs, rhs) - } + package func discardCandidates( + id: WebInspectorQueryRegistrationID, + through generation: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + let candidate = registration.candidate, + candidate.generation <= generation else { + return } - - let lowerBound = min(plan.fetchOffset, records.count) - let upperBound: Int - if let fetchLimit = plan.fetchLimit { - upperBound = min(lowerBound + fetchLimit, records.count) - } else { - upperBound = records.count - } - return Array(records[lowerBound.. - ) -> (id: WebInspectorFetchSectionID, title: String?) { - let value: String? - switch sectionBy.key { - case .networkMethod: - value = record.method - case .networkResourceType: - value = record.resourceTypeRawValue - case .networkResourceCategory: - value = record.resourceCategory.rawValue - case .networkMIMEType: - value = record.mimeType - case .consoleSource, - .consoleLevel, - .consoleKind, - .consoleURL: - preconditionFailure("Console section descriptors cannot be applied to NetworkRequest results.") - } - - let title = value ?? "" - return (WebInspectorFetchSectionID(rawValue: title), title) + registration.candidate = nil + queryRegistrations[id] = registration } -} -private enum NetworkResultSetTransactionBuilder { - typealias Snapshot = WebInspectorFetchedResultsSnapshot - typealias ItemID = NetworkRequest.ID - - static func transaction( - oldSnapshot: Snapshot, - newSnapshot: Snapshot, - changedID: ItemID? - ) -> WebInspectorFetchedResultsTransaction? { - let sectionChanges = sectionChanges(from: oldSnapshot, to: newSnapshot) - let itemChanges = itemChanges(from: oldSnapshot, to: newSnapshot, changedID: changedID) - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - isReset: false, - sectionChanges: sectionChanges, - itemChanges: itemChanges + package func acknowledge( + id: WebInspectorQueryRegistrationID, + generation: UInt64, + sourceEpoch: UInt64, + sequence: UInt64 + ) { + pruneQueryRegistrations() + guard var registration = queryRegistrations[id], + registration.active.generation == generation, + self.sourceEpoch == sourceEpoch else { + return + } + registration.active.acknowledgedSequence = max( + registration.active.acknowledgedSequence, + sequence ) - return transaction.hasChanges ? transaction : nil + queryRegistrations[id] = registration } - private static func sectionChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot - ) -> [WebInspectorFetchedResultsSectionChange] { - let oldIndexes = indexSections(oldSnapshot.sections) - let newIndexes = indexSections(newSnapshot.sections) - - let deletes = oldSnapshot.sections.enumerated() - .filter { _, section in newIndexes[section.id] == nil } - .sorted { lhs, rhs in lhs.offset > rhs.offset } - .map { index, section in - WebInspectorFetchedResultsSectionChange.delete(sectionID: section.id, index: index) - } - - let inserts = newSnapshot.sections.enumerated() - .filter { _, section in oldIndexes[section.id] == nil } - .map { index, section in - WebInspectorFetchedResultsSectionChange.insert(sectionID: section.id, index: index) - } - - let moves = newSnapshot.sections.enumerated() - .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in - guard let oldIndex = oldIndexes[section.id], oldIndex != newIndex else { - return nil + private func waitUntilApplied(_ minimumSequence: UInt64) async throws { + try Task.checkCancellation() + guard lastAppliedSequence < minimumSequence else { + return + } + precondition( + nextSequenceWaiterID < UInt64.max, + "NetworkRequestIndex sequence waiter identity overflowed." + ) + let waiterID = nextSequenceWaiterID + nextSequenceWaiterID += 1 + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + sequenceWaiters[waiterID] = SequenceWaiter( + minimumSequence: minimumSequence, + continuation: continuation + ) + if Task.isCancelled { + cancelSequenceWaiter(id: waiterID) + } else { + resumeSequenceWaiters() } - return .move(sectionID: section.id, from: oldIndex, to: newIndex) } - - let updates = newSnapshot.sections.enumerated() - .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in - guard let oldIndex = oldIndexes[section.id] else { - return nil - } - guard oldSnapshot.sections[oldIndex].title != section.title else { - return nil - } - return .update(sectionID: section.id, index: newIndex) + } onCancel: { + Task { + await self.cancelSequenceWaiter(id: waiterID) } + } + } - return deletes + inserts + moves + updates + private func cancelSequenceWaiter(id: UInt64) { + sequenceWaiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) } - private static func itemChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot, - changedID: ItemID? - ) -> [WebInspectorFetchedResultsItemChange] { - let oldPositions = indexItems(oldSnapshot) - let newPositions = indexItems(newSnapshot) + private func resumeSequenceWaiters() { + let readyIDs = sequenceWaiters.compactMap { id, waiter in + waiter.minimumSequence <= lastAppliedSequence ? id : nil + } + for id in readyIDs { + sequenceWaiters.removeValue(forKey: id)?.continuation.resume(returning: ()) + } + } - let deletes = oldPositions.values - .filter { newPositions[$0.itemID] == nil } - .sorted { lhs, rhs in lhs.indexPath > rhs.indexPath } - .map { - WebInspectorFetchedResultsItemChange.delete( - itemID: $0.itemID, - indexPath: $0.indexPath + private func updateAllQueryRegistrations( + sequence: UInt64, + rebuilding: Bool, + changedID: NetworkRequest.ID? = nil + ) -> [QueryDelivery] { + pruneQueryRegistrations() + var deliveries: [QueryDelivery] = [] + deliveries.reserveCapacity(queryRegistrations.count * 2) + for id in Array(queryRegistrations.keys) { + guard var registration = queryRegistrations[id] else { + continue + } + update( + ®istration.active, + sequence: sequence, + rebuilding: rebuilding, + changedID: changedID + ) + deliveries.append(QueryDelivery( + registrationID: id, + generation: registration.active.generation, + projection: projection(for: registration.active) + )) + if var candidate = registration.candidate { + update( + &candidate, + sequence: sequence, + rebuilding: rebuilding, + changedID: changedID ) + registration.candidate = candidate + deliveries.append(QueryDelivery( + registrationID: id, + generation: candidate.generation, + projection: projection(for: candidate) + )) } + queryRegistrations[id] = registration + } + return deliveries + } - let inserts = newPositions.values - .filter { oldPositions[$0.itemID] == nil } - .sorted { lhs, rhs in lhs.indexPath < rhs.indexPath } - .map { - WebInspectorFetchedResultsItemChange.insert( - itemID: $0.itemID, - indexPath: $0.indexPath - ) + private func update( + _ version: inout QueryVersion, + sequence: UInt64, + rebuilding: Bool, + changedID: NetworkRequest.ID? + ) { + if rebuilding { + version.matchingIDs = matchingIDs(for: version.query) + } else if let changedID { + version.matchingIDs.removeAll { $0 == changedID } + if let record = recordsByID[changedID], matches(record, query: version.query) { + insert(changedID, into: &version.matchingIDs, query: version.query) } + } + version.snapshot = snapshot(matchingIDs: version.matchingIDs, query: version.query) + version.sequence = sequence + } - let sectionMembershipChanges = sectionMembershipChanges( - from: oldSnapshot, - to: newSnapshot, - oldPositions: oldPositions, - newPositions: newPositions + private func makeQueryVersion( + generation: UInt64, + query: NetworkQuery, + acknowledgedSequence: UInt64 + ) -> QueryVersion { + let matchingIDs = matchingIDs(for: query) + return QueryVersion( + generation: generation, + query: query, + matchingIDs: matchingIDs, + snapshot: snapshot(matchingIDs: matchingIDs, query: query), + sequence: lastAppliedSequence, + acknowledgedSequence: acknowledgedSequence ) + } - let moves = moveChanges( - from: oldSnapshot, - to: newSnapshot, - oldPositions: oldPositions, - newPositions: newPositions, - changedID: changedID, - excludedItemIDs: Set(sectionMembershipChanges.map(itemID)) + private func projection(for version: QueryVersion) -> QueryProjection { + let reconfigureItemIDs = Set(version.snapshot.itemIDs.filter { id in + lastUpdatedSequenceByID[id, default: 0] > version.acknowledgedSequence + }) + return QueryProjection( + sourceEpoch: sourceEpoch, + sequence: version.sequence, + snapshot: version.snapshot, + reconfigureItemIDs: reconfigureItemIDs ) + } - return deletes + inserts + sectionMembershipChanges + moves - } - - private static func sectionMembershipChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot, - oldPositions: [ItemID: ItemPosition], - newPositions: [ItemID: ItemPosition] - ) -> [WebInspectorFetchedResultsItemChange] { - let oldSectionIDs = Set(oldSnapshot.sectionIDs) - let newSectionIDs = Set(newSnapshot.sectionIDs) - let deletedSectionIDs = oldSectionIDs.subtracting(newSectionIDs) - let insertedSectionIDs = newSectionIDs.subtracting(oldSectionIDs) - - return newSnapshot.itemIDs.compactMap { itemID -> WebInspectorFetchedResultsItemChange? in - guard let oldPosition = oldPositions[itemID], - let newPosition = newPositions[itemID], - oldPosition.sectionID != newPosition.sectionID else { - return nil - } - let oldSectionDeleted = deletedSectionIDs.contains(oldPosition.sectionID) - let newSectionInserted = insertedSectionIDs.contains(newPosition.sectionID) - switch (oldSectionDeleted, newSectionInserted) { - case (true, true): - return nil - case (true, false): - return .insert(itemID: itemID, indexPath: newPosition.indexPath) - case (false, true): - return .delete(itemID: itemID, indexPath: oldPosition.indexPath) - case (false, false): - return .move( - itemID: itemID, - from: oldPosition.indexPath, - to: newPosition.indexPath - ) - } + private func pruneQueryRegistrations() { + queryRegistrations = queryRegistrations.filter { _, registration in + registration.lifetime.value != nil } } - private static func moveChanges( - from oldSnapshot: Snapshot, - to newSnapshot: Snapshot, - oldPositions: [ItemID: ItemPosition], - newPositions: [ItemID: ItemPosition], - changedID: ItemID?, - excludedItemIDs: Set - ) -> [WebInspectorFetchedResultsItemChange] { - let oldCommonOrder = oldSnapshot.itemIDs.filter { newPositions[$0] != nil } - let newCommonOrder = newSnapshot.itemIDs.filter { oldPositions[$0] != nil } - guard oldCommonOrder != newCommonOrder else { - return [] - } - - if let changedID, - excludedItemIDs.contains(changedID) == false, - let oldPosition = oldPositions[changedID], - let newPosition = newPositions[changedID], - oldPosition.sectionID == newPosition.sectionID, - oldPosition.indexPath != newPosition.indexPath { - return [ - .move( - itemID: changedID, - from: oldPosition.indexPath, - to: newPosition.indexPath - ), - ] - } - - return newCommonOrder.compactMap { itemID -> WebInspectorFetchedResultsItemChange? in - guard excludedItemIDs.contains(itemID) == false else { - return nil - } - guard let oldPosition = oldPositions[itemID], - let newPosition = newPositions[itemID], - oldPosition.sectionID == newPosition.sectionID, - oldPosition.indexPath != newPosition.indexPath else { - return nil +#if DEBUG + package func isMutationPendingForTesting(sequence: UInt64) -> Bool { + pendingMutationsBySequence[sequence] != nil + } + + package func isSequenceWaiterPendingForTesting(minimumSequence: UInt64) -> Bool { + sequenceWaiters.values.contains { $0.minimumSequence == minimumSequence } + } + + package func queryRegistrationCountForTesting() -> Int { + pruneQueryRegistrations() + return queryRegistrations.count + } +#endif + + private func matchingIDs(for query: NetworkQuery) -> [NetworkRequest.ID] { + var ids = orderedIDs.filter { id in + recordsByID[id].map { matches($0, query: query) } ?? false + } + ids.sort { lhsID, rhsID in + guard let lhs = recordsByID[lhsID], let rhs = recordsByID[rhsID] else { + preconditionFailure("NetworkRequestIndex lost a matching record while sorting a query.") } - return .move( - itemID: itemID, - from: oldPosition.indexPath, - to: newPosition.indexPath - ) + return ordersBefore(lhs, rhs, query: query) } + return ids } - private static func indexSections( - _ sections: [Snapshot.Section] - ) -> [WebInspectorFetchSectionID: Int] { - Dictionary( - uniqueKeysWithValues: sections.enumerated().map { index, section in - (section.id, index) + private func matches(_ record: NetworkRequestRecord, query: NetworkQuery) -> Bool { + if let search = query.search, + record.searchableText.localizedStandardContains(search) == false { + return false + } + if query.resourceCategories.isEmpty == false, + query.resourceCategories.contains(record.resourceCategory) == false { + return false + } + if query.methods.isEmpty == false, + query.methods.contains(record.method) == false { + return false + } + return true + } + + private func insert( + _ id: NetworkRequest.ID, + into ids: inout [NetworkRequest.ID], + query: NetworkQuery + ) { + guard let record = recordsByID[id] else { + return + } + var lowerBound = 0 + var upperBound = ids.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + guard let midpointRecord = recordsByID[ids[midpoint]] else { + preconditionFailure("NetworkRequestIndex lost a matching record during insertion.") } - ) + if ordersBefore(midpointRecord, record, query: query) { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + ids.insert(id, at: lowerBound) } - private struct ItemPosition { - var itemID: ItemID - var sectionID: WebInspectorFetchSectionID - var indexPath: WebInspectorFetchedResultsIndexPath + private func ordersBefore( + _ lhs: NetworkRequestRecord, + _ rhs: NetworkRequestRecord, + query: NetworkQuery + ) -> Bool { + let timestampOrder = compareOptional(lhs.requestSentTimestamp, rhs.requestSentTimestamp) + switch (query.sort, timestampOrder) { + case (.requestTimeAscending, .orderedAscending), + (.requestTimeDescending, .orderedDescending): + return true + case (.requestTimeAscending, .orderedDescending), + (.requestTimeDescending, .orderedAscending): + return false + case (_, .orderedSame): + switch query.sort { + case .requestTimeAscending: + return lhs.orderIndex < rhs.orderIndex + case .requestTimeDescending: + return lhs.orderIndex > rhs.orderIndex + } + } } - private static func indexItems(_ snapshot: Snapshot) -> [ItemID: ItemPosition] { - var positions: [ItemID: ItemPosition] = [:] - for (sectionIndex, section) in snapshot.sections.enumerated() { - for (itemIndex, itemID) in section.itemIDs.enumerated() where positions[itemID] == nil { - positions[itemID] = ItemPosition( - itemID: itemID, - sectionID: section.id, - indexPath: WebInspectorFetchedResultsIndexPath( - section: sectionIndex, - item: itemIndex - ) - ) + private func compareOptional( + _ lhs: Value?, + _ rhs: Value? + ) -> ComparisonResult { + switch (lhs, rhs) { + case (nil, nil): + return .orderedSame + case (nil, _): + return .orderedAscending + case (_, nil): + return .orderedDescending + case let (lhs?, rhs?): + if lhs < rhs { + return .orderedAscending + } + if lhs > rhs { + return .orderedDescending } + return .orderedSame } - return positions } - private static func itemID( - for change: WebInspectorFetchedResultsItemChange - ) -> ItemID { - switch change { - case let .insert(itemID, _), - let .delete(itemID, _), - let .update(itemID, _), - let .move(itemID, _, _): - return itemID + private func snapshot( + matchingIDs: [NetworkRequest.ID], + query: NetworkQuery + ) -> WebInspectorFetchedResultsSnapshot { + let lowerBound = min(query.offset, matchingIDs.count) + let upperBound: Int + if let limit = query.limit { + upperBound = min(lowerBound + limit, matchingIDs.count) + } else { + upperBound = matchingIDs.count + } + let visibleIDs = Array(matchingIDs[lowerBound.. Bool - - package enum Filter: Sendable { - case record(RecordPredicate) - case model(Predicate) - - package func matches(record: NetworkRequestRecord) -> Bool? { - switch self { - case let .record(predicate): - return predicate(record) - case .model: - return nil - } - } - - package func matches(record: NetworkRequestRecord, request: NetworkRequest) -> Bool { - switch self { - case let .record(predicate): - return predicate(record) - case let .model(predicate): - do { - return try predicate.evaluate(request) - } catch { - preconditionFailure("NetworkRequest predicate evaluation failed: \(error)") - } - } - } - } - - package var filter: Filter? - package var sortComparators: [NetworkRequestRecordSortComparator] - package var fetchLimit: Int? - package var fetchOffset: Int - - package init( - descriptor: WebInspectorFetchDescriptor, - context: WebInspectorContext - ) { - filter = descriptor.predicate.map(makeNetworkRequestFilter) - sortComparators = descriptor.sortBy.map { - NetworkRequestRecordSortComparator(descriptor: $0, context: context) - } - fetchLimit = descriptor.fetchLimit - fetchOffset = descriptor.fetchOffset - } - - package var requiresQuery: Bool { - filter != nil || sortComparators.isEmpty == false || fetchLimit != nil || fetchOffset > 0 - } - - package var requiresModelPredicate: Bool { - guard case .model = filter else { - return false - } - return true - } - - package func matches(record: NetworkRequestRecord) -> Bool? { - filter?.matches(record: record) ?? true - } - - package func matches(record: NetworkRequestRecord, request: NetworkRequest) -> Bool { - filter?.matches(record: record, request: request) ?? true - } - - package func visibleIDs(from matchingIDs: [NetworkRequest.ID]) -> ArraySlice { - let lowerBound = min(fetchOffset, matchingIDs.count) - let upperBound: Int - if let fetchLimit { - upperBound = min(lowerBound + fetchLimit, matchingIDs.count) - } else { - upperBound = matchingIDs.count - } - return matchingIDs[lowerBound.. Bool { - for comparator in sortComparators { - switch comparator.compare(lhs, rhs) { - case .orderedAscending: - return true - case .orderedDescending: - return false - case .orderedSame: - continue - } - } - if sortComparators.first?.usesReverseOrder == true { - return lhs.orderIndex > rhs.orderIndex - } - return lhs.orderIndex < rhs.orderIndex - } -} - -package struct NetworkRequestQueryState { - package var plan: NetworkRequestQueryPlan - private var recordsByID: [NetworkRequest.ID: NetworkRequestRecord] - private var matchingIDs: [NetworkRequest.ID] - - package init(plan: NetworkRequestQueryPlan, requests: [NetworkRequest]) { - self.plan = plan - recordsByID = [:] - recordsByID.reserveCapacity(requests.count) - matchingIDs = [] - matchingIDs.reserveCapacity(requests.count) - for (index, request) in requests.enumerated() { - upsert(request: request, orderIndex: index) - } - } - - private mutating func upsert(request: NetworkRequest, orderIndex: Int) { - let record = NetworkRequestRecord(request: request, orderIndex: orderIndex) - recordsByID[record.id] = record - matchingIDs.removeAll { $0 == record.id } - guard plan.matches(record: record, request: request) else { - return - } - insertMatchingID(record.id) - } - - package mutating func upsert(request: NetworkRequest) { - let orderIndex = recordsByID[request.id]?.orderIndex ?? recordsByID.count - upsert(request: request, orderIndex: orderIndex) - } - - package func visibleRequests( - lookup: (NetworkRequest.ID) -> NetworkRequest? - ) -> [NetworkRequest] { - plan.visibleIDs(from: matchingIDs).compactMap(lookup) - } - - private mutating func insertMatchingID(_ id: NetworkRequest.ID) { - guard let record = recordsByID[id] else { - return - } - var lowerBound = 0 - var upperBound = matchingIDs.count - while lowerBound < upperBound { - let midpoint = (lowerBound + upperBound) / 2 - guard let midpointRecord = recordsByID[matchingIDs[midpoint]] else { - lowerBound = midpoint + 1 - continue - } - if plan.ordersBefore(midpointRecord, record) { - lowerBound = midpoint + 1 - } else { - upperBound = midpoint - } - } - matchingIDs.insert(id, at: lowerBound) - } -} - -package struct NetworkRequestRecordSortComparator: Sendable { - private enum Key: Sendable { - case requestSentTimestamp - } - - private var key: Key - private var order: SortOrder - - fileprivate init( - descriptor: SortDescriptor, - context: WebInspectorContext - ) { - guard let key = Self.key(for: descriptor, context: context) else { - preconditionFailure("Unsupported NetworkRequest sort descriptor: \(descriptor)") - } - self.key = key - order = descriptor.order - } - - fileprivate func compare( - _ lhs: NetworkRequestRecord, - _ rhs: NetworkRequestRecord - ) -> ComparisonResult { - let result: ComparisonResult - switch key { - case .requestSentTimestamp: - result = compareOptional(lhs.requestSentTimestamp, rhs.requestSentTimestamp) - } - switch order { - case .forward: - return result - case .reverse: - return result.reversed - } - } - - fileprivate var usesReverseOrder: Bool { - order == .reverse - } - - private static func key( - for descriptor: SortDescriptor, - context: WebInspectorContext - ) -> Key? { - let timestampPair = sentinelPair( - lhsTimestamp: 1, - rhsTimestamp: 2, - context: context - ) - if descriptor.compare(timestampPair.lhs, timestampPair.rhs) != .orderedSame { - return .requestSentTimestamp - } - return nil - } - - private static func sentinelPair( - lhsTimestamp: Double, - rhsTimestamp: Double, - context: WebInspectorContext - ) -> (lhs: NetworkRequest, rhs: NetworkRequest) { - ( - lhs: NetworkRequest( - request: Network.Request( - id: Network.Request.ID("__sort-lhs"), - url: "https://example.test/resource", - method: "GET" - ), - resourceType: .fetch, - timestamp: lhsTimestamp, - modelContext: context - ), - rhs: NetworkRequest( - request: Network.Request( - id: Network.Request.ID("__sort-rhs"), - url: "https://example.test/resource", - method: "GET" - ), - resourceType: .fetch, - timestamp: rhsTimestamp, - modelContext: context - ) - ) - } - - private func compareOptional( - _ lhs: Value?, - _ rhs: Value? - ) -> ComparisonResult { - switch (lhs, rhs) { - case (nil, nil): - return .orderedSame - case (nil, _): - return .orderedAscending - case (_, nil): - return .orderedDescending - case let (lhs?, rhs?): - if lhs < rhs { - return .orderedAscending - } - if lhs > rhs { - return .orderedDescending - } - return .orderedSame - } - } -} - -private extension ComparisonResult { - var reversed: ComparisonResult { - switch self { - case .orderedAscending: - return .orderedDescending - case .orderedDescending: - return .orderedAscending - case .orderedSame: - return .orderedSame - } - } -} - -private protocol NetworkRequestRecordPredicateExpression { - func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate -} - -private protocol NetworkRequestRecordStringExpression { - func networkRequestStringExpression() throws -> @Sendable (NetworkRequestRecord) -> String -} - -private protocol NetworkRequestRecordIntExpression { - func networkRequestIntExpression() throws -> @Sendable (NetworkRequestRecord) -> Int -} - -private protocol NetworkRequestRecordOptionalIntExpression { - func networkRequestOptionalIntExpression() throws -> @Sendable (NetworkRequestRecord) -> Int? -} - -private protocol NetworkRequestRecordResourceCategoryExpression { - func networkRequestResourceCategoryExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequest.ResourceCategory -} - -private protocol NetworkRequestRecordResourceCategorySequenceExpression { - func networkRequestResourceCategorySequenceExpression() throws -> [NetworkRequest.ResourceCategory] -} - -private enum NetworkRequestRecordPredicateValue: Equatable, Sendable { - case string(String) - case optionalString(String?) - case resourceCategory(NetworkRequest.ResourceCategory) -} - -private protocol NetworkRequestRecordEquatableExpression { - func networkRequestEquatableExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequestRecordPredicateValue -} - -private struct UnsupportedNetworkRequestRecordPredicate: Error {} - -private func makeNetworkRequestFilter( - _ predicate: Predicate -) -> NetworkRequestQueryPlan.Filter { - guard let expression = predicate.expression as? any NetworkRequestRecordPredicateExpression else { - return .model(predicate) - } - do { - return .record(try expression.networkRequestRecordPredicate()) - } catch is UnsupportedNetworkRequestRecordPredicate { - return .model(predicate) - } catch { - preconditionFailure("NetworkRequest predicate planning failed: \(error)") - } -} - -extension PredicateExpressions.Conjunction: NetworkRequestRecordPredicateExpression - where LHS: NetworkRequestRecordPredicateExpression, RHS: NetworkRequestRecordPredicateExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let lhsPredicate = try lhs.networkRequestRecordPredicate() - let rhsPredicate = try rhs.networkRequestRecordPredicate() - return { record in - lhsPredicate(record) && rhsPredicate(record) - } - } -} - -extension PredicateExpressions.Disjunction: NetworkRequestRecordPredicateExpression - where LHS: NetworkRequestRecordPredicateExpression, RHS: NetworkRequestRecordPredicateExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let lhsPredicate = try lhs.networkRequestRecordPredicate() - let rhsPredicate = try rhs.networkRequestRecordPredicate() - return { record in - lhsPredicate(record) || rhsPredicate(record) - } - } -} - -extension PredicateExpressions.Equal: NetworkRequestRecordPredicateExpression - where LHS: NetworkRequestRecordEquatableExpression, RHS: NetworkRequestRecordEquatableExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let lhsExpression = try lhs.networkRequestEquatableExpression() - let rhsExpression = try rhs.networkRequestEquatableExpression() - return { record in - networkRequestRecordPredicateValuesEqual(lhsExpression(record), rhsExpression(record)) - } - } -} - -private func networkRequestRecordPredicateValuesEqual( - _ lhs: NetworkRequestRecordPredicateValue, - _ rhs: NetworkRequestRecordPredicateValue -) -> Bool { - switch (lhs, rhs) { - case let (.string(lhs), .string(rhs)): - return lhs == rhs - case let (.optionalString(lhs), .optionalString(rhs)): - return lhs == rhs - case let (.optionalString(lhs), .string(rhs)): - return lhs == rhs - case let (.string(lhs), .optionalString(rhs)): - return lhs == rhs - case let (.resourceCategory(lhs), .resourceCategory(rhs)): - return lhs == rhs - case (.string, _), - (.optionalString, _), - (.resourceCategory, _): - return false - } -} - -extension PredicateExpressions.StringLocalizedStandardContains: NetworkRequestRecordPredicateExpression - where Root: NetworkRequestRecordStringExpression, Other: NetworkRequestRecordStringExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let rootExpression = try root.networkRequestStringExpression() - let otherExpression = try other.networkRequestStringExpression() - return { record in - rootExpression(record).localizedStandardContains(otherExpression(record)) - } - } -} - -extension PredicateExpressions.SequenceContains: NetworkRequestRecordPredicateExpression - where LHS: NetworkRequestRecordResourceCategorySequenceExpression, - RHS: NetworkRequestRecordResourceCategoryExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let categories = Set(try sequence.networkRequestResourceCategorySequenceExpression()) - let elementExpression = try element.networkRequestResourceCategoryExpression() - return { record in - categories.contains(elementExpression(record)) - } - } -} - -extension PredicateExpressions.Comparison: NetworkRequestRecordPredicateExpression - where LHS: NetworkRequestRecordIntExpression, RHS: NetworkRequestRecordIntExpression -{ - fileprivate func networkRequestRecordPredicate() throws -> NetworkRequestQueryPlan.RecordPredicate { - let lhsExpression = try lhs.networkRequestIntExpression() - let rhsExpression = try rhs.networkRequestIntExpression() - let op = op - return { record in - let lhs = lhsExpression(record) - let rhs = rhsExpression(record) - switch op { - case .lessThan: - return lhs < rhs - case .lessThanOrEqual: - return lhs <= rhs - case .greaterThan: - return lhs > rhs - case .greaterThanOrEqual: - return lhs >= rhs - @unknown default: - preconditionFailure("Unsupported NetworkRequest comparison operator: \(op)") - } - } - } -} - -extension PredicateExpressions.NilCoalesce: NetworkRequestRecordIntExpression - where LHS: NetworkRequestRecordOptionalIntExpression, RHS: NetworkRequestRecordIntExpression -{ - fileprivate func networkRequestIntExpression() throws -> @Sendable (NetworkRequestRecord) -> Int { - let lhsExpression = try lhs.networkRequestOptionalIntExpression() - let rhsExpression = try rhs.networkRequestIntExpression() - return { record in - lhsExpression(record) ?? rhsExpression(record) - } - } -} - -extension PredicateExpressions.KeyPath: NetworkRequestRecordStringExpression - where Root == PredicateExpressions.Variable, Output == String -{ - fileprivate func networkRequestStringExpression() throws -> @Sendable (NetworkRequestRecord) -> String { - if keyPath == \NetworkRequest.url { - return { $0.url } - } - if keyPath == \NetworkRequest.method { - return { $0.method } - } - if keyPath == \NetworkRequest.searchableText { - return { $0.searchableText } - } - throw UnsupportedNetworkRequestRecordPredicate() - } -} - -extension PredicateExpressions.KeyPath: NetworkRequestRecordEquatableExpression - where Root == PredicateExpressions.Variable -{ - fileprivate func networkRequestEquatableExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequestRecordPredicateValue { - if keyPath == \NetworkRequest.url || keyPath == \NetworkRequest.method || keyPath == \NetworkRequest.searchableText { - let stringExpression: @Sendable (NetworkRequestRecord) -> String - if keyPath == \NetworkRequest.url { - stringExpression = { $0.url } - } else if keyPath == \NetworkRequest.method { - stringExpression = { $0.method } - } else { - stringExpression = { $0.searchableText } - } - return { record in - .string(stringExpression(record)) - } - } - if keyPath == \NetworkRequest.mimeType { - return { record in - .optionalString(record.mimeType) - } - } - if keyPath == \NetworkRequest.resourceCategory { - return { record in - .resourceCategory(record.resourceCategory) - } - } - throw UnsupportedNetworkRequestRecordPredicate() - } -} - -extension PredicateExpressions.KeyPath: NetworkRequestRecordOptionalIntExpression - where Root == PredicateExpressions.Variable, Output == Int? -{ - fileprivate func networkRequestOptionalIntExpression() throws -> @Sendable (NetworkRequestRecord) -> Int? { - if keyPath == \NetworkRequest.statusCode { - return { $0.statusCode } - } - throw UnsupportedNetworkRequestRecordPredicate() - } -} - -extension PredicateExpressions.KeyPath: NetworkRequestRecordResourceCategoryExpression - where Root == PredicateExpressions.Variable, Output == NetworkRequest.ResourceCategory -{ - fileprivate func networkRequestResourceCategoryExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequest.ResourceCategory { - if keyPath == \NetworkRequest.resourceCategory { - return { $0.resourceCategory } - } - throw UnsupportedNetworkRequestRecordPredicate() - } -} - -extension PredicateExpressions.Value: NetworkRequestRecordStringExpression where Output == String { - fileprivate func networkRequestStringExpression() throws -> @Sendable (NetworkRequestRecord) -> String { - let value = value - return { _ in value } - } -} - -extension PredicateExpressions.Value: NetworkRequestRecordEquatableExpression { - fileprivate func networkRequestEquatableExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequestRecordPredicateValue { - if let value = value as? String { - return { _ in .string(value) } - } - if Output.self == Optional.self { - let value = value as! String? - return { _ in .optionalString(value) } - } - if let value = value as? NetworkRequest.ResourceCategory { - return { _ in .resourceCategory(value) } - } - throw UnsupportedNetworkRequestRecordPredicate() - } -} - -extension PredicateExpressions.Value: NetworkRequestRecordIntExpression where Output == Int { - fileprivate func networkRequestIntExpression() throws -> @Sendable (NetworkRequestRecord) -> Int { - let value = value - return { _ in value } - } -} - -extension PredicateExpressions.Value: NetworkRequestRecordResourceCategoryExpression - where Output == NetworkRequest.ResourceCategory -{ - fileprivate func networkRequestResourceCategoryExpression() throws -> @Sendable (NetworkRequestRecord) -> NetworkRequest.ResourceCategory { - let value = value - return { _ in value } - } -} - -extension PredicateExpressions.Value: NetworkRequestRecordResourceCategorySequenceExpression - where Output == [NetworkRequest.ResourceCategory] -{ - fileprivate func networkRequestResourceCategorySequenceExpression() throws -> [NetworkRequest.ResourceCategory] { - value - } -} diff --git a/Sources/WebInspectorDataKit/NetworkRequestStore.swift b/Sources/WebInspectorDataKit/NetworkRequestStore.swift new file mode 100644 index 00000000..d0a4bb17 --- /dev/null +++ b/Sources/WebInspectorDataKit/NetworkRequestStore.swift @@ -0,0 +1,1360 @@ +import Foundation +import WebInspectorProxyKit + +/// Owns Network request identity, order, query projection, and publication. +/// +/// `WebInspectorModelContext` remains the attachment and transport coordinator. The +/// model context confines access to the store, which never retains an actor token +/// or starts transport work. +package final class NetworkRequestStore { + package struct IndexWork: Sendable { + fileprivate enum Action: Sendable { + case replace( + inputs: [NetworkRequestRecordInput], + sequence: UInt64, + sourceEpoch: UInt64 + ) + case upsert(input: NetworkRequestRecordInput, sequence: UInt64) + } + + fileprivate let index: NetworkRequestIndex + fileprivate let actions: [Action] + + package nonisolated(nonsending) func run() async -> IndexResult { + var deliveries: [NetworkRequestIndex.QueryDelivery] = [] + for action in actions { + switch action { + case let .replace(inputs, sequence, sourceEpoch): + deliveries += await index.replace( + with: inputs, + sequence: sequence, + sourceEpoch: sourceEpoch + ) + case let .upsert(input, sequence): + deliveries += await index.upsert(input, sequence: sequence) + } + } + return IndexResult(deliveries: deliveries) + } + } + + package struct IndexResult: Sendable { + fileprivate let deliveries: [NetworkRequestIndex.QueryDelivery] + } + + package struct IndexAcknowledgementWork: Sendable { + fileprivate struct Entry: Sendable { + let id: WebInspectorQueryRegistrationID + let generation: UInt64 + let sourceEpoch: UInt64 + let sequence: UInt64 + } + + fileprivate let index: NetworkRequestIndex + fileprivate let entries: [Entry] + + package nonisolated(nonsending) func run() async { + for entry in entries { + await index.acknowledge( + id: entry.id, + generation: entry.generation, + sourceEpoch: entry.sourceEpoch, + sequence: entry.sequence + ) + } + } + } + + private enum ConcreteQueryBufferDestination { + case candidate + case committing + } + + private struct PendingConcreteQuery { + var generation: UInt64 + var query: NetworkQuery + var projection: NetworkRequestIndex.QueryProjection? + } + + private struct ConcreteQueryRegistration { + var results: WeakWebInspectorFetchedResults + var activeGeneration: UInt64 + var activeQuery: NetworkQuery + var candidate: PendingConcreteQuery? + var committing: [UInt64: PendingConcreteQuery] + } + + package struct QueryIndexReset: Sendable { + fileprivate var sequence: UInt64 + fileprivate var sourceEpoch: UInt64 + } + + private struct ModelChange { + let request: NetworkRequest + let inserted: Bool + } + +#if DEBUG + package struct PerformanceCounters: Equatable { + package var fullModelProjectionCount = 0 + package var fullRecordProjectionCount = 0 + package var incrementalRecordProjectionCount = 0 + package var resultIdentityLookupCount = 0 + } +#endif + + package let collectionState: NetworkRequestCollectionState + + private var requestsByID: [NetworkRequest.ID: NetworkRequest] + private var orderedRequestIDs: [NetworkRequest.ID] + private var orderIndicesByID: [NetworkRequest.ID: Int] + private var clearedRequestIDs: Set + private let queryIndex: NetworkRequestIndex + private var queryIndexSequence: UInt64 + private var queryIndexNeedsRebuild: Bool + private var querySourceEpoch: UInt64 + private var nextConcreteQueryRegistrationID: UInt64 + private var initializingConcreteQueries: [ + WebInspectorQueryRegistrationID: PendingConcreteQuery + ] + private var concreteQueryRegistrations: [ + WebInspectorQueryRegistrationID: ConcreteQueryRegistration + ] +#if DEBUG + private var performanceCounters: PerformanceCounters +#endif + + package init() { + collectionState = NetworkRequestCollectionState() + requestsByID = [:] + orderedRequestIDs = [] + orderIndicesByID = [:] + clearedRequestIDs = [] + queryIndex = NetworkRequestIndex() + queryIndexSequence = 0 + queryIndexNeedsRebuild = false + querySourceEpoch = 0 + nextConcreteQueryRegistrationID = 0 + initializingConcreteQueries = [:] + concreteQueryRegistrations = [:] +#if DEBUG + performanceCounters = PerformanceCounters() +#endif + } + +#if DEBUG + package var performanceCountersForTesting: PerformanceCounters { + performanceCounters + } + + package func resetPerformanceCountersForTesting() { + performanceCounters = PerformanceCounters() + } +#endif + + package func request(for id: NetworkRequest.ID) -> NetworkRequest? { + requestsByID[id] + } + + package func request(forProxyID id: Network.Request.ID) -> NetworkRequest? { + request(for: NetworkRequest.ID(id)) + } + + package nonisolated(nonsending) func results( + matching query: NetworkQuery, + modelContext: WebInspectorModelContext + ) async throws -> WebInspectorFetchedResults { + await syncQueryIndexIfNeeded() + let id = allocateConcreteQueryRegistrationID() + let lifetime = WebInspectorQueryRegistrationLifetime() + let results = WebInspectorFetchedResults(modelContext: modelContext) + results.installQueryRegistration(id: id, lifetime: lifetime) + let generation = results.nextConcreteQueryGeneration() + initializingConcreteQueries[id] = PendingConcreteQuery( + generation: generation, + query: query, + projection: nil + ) + + let initialProjection: NetworkRequestIndex.QueryProjection + do { + initialProjection = try await queryIndex.register( + id: id, + generation: generation, + query: query, + lifetime: lifetime, + minimumSequence: queryIndexSequence + ) + } catch { + initializingConcreteQueries[id] = nil + throw error + } + + guard var initialization = initializingConcreteQueries.removeValue(forKey: id), + initialization.generation == generation else { + preconditionFailure("Network query initialization lost its owner state after index commit.") + } + initialization.projection = coalesce( + initialization.projection, + with: initialProjection + ) + let installedProjection = initialization.projection ?? initialProjection + guard installedProjection.sourceEpoch == querySourceEpoch else { + throw CancellationError() + } + results.installInitialNetworkQuery( + query, + generation: generation, + projection: installedProjection, + lookup: { id in self.requestForResult(id) } + ) + concreteQueryRegistrations[id] = ConcreteQueryRegistration( + results: WeakWebInspectorFetchedResults(results), + activeGeneration: generation, + activeQuery: query, + candidate: nil, + committing: [:] + ) + await queryIndex.acknowledge( + id: id, + generation: generation, + sourceEpoch: installedProjection.sourceEpoch, + sequence: installedProjection.sequence + ) + return results + } + + package nonisolated(nonsending) func update( + _ query: NetworkQuery, + for results: WebInspectorFetchedResults + ) async throws { + guard let id = results.concreteQueryRegistrationID else { + preconditionFailure("Network fetched results are not registered in this store.") + } + await syncQueryIndexIfNeeded() + guard var registration = concreteQueryRegistrations[id], + registration.results.value === results else { + preconditionFailure("Network fetched results are not registered in this store.") + } + let generation = results.nextConcreteQueryGeneration() + registration.candidate = PendingConcreteQuery( + generation: generation, + query: query, + projection: nil + ) + concreteQueryRegistrations[id] = registration + + do { + let prepared = try await queryIndex.prepareReplacement( + id: id, + generation: generation, + query: query, + minimumSequence: queryIndexSequence + ) + buffer( + prepared, + for: id, + generation: generation, + query: query, + destination: .candidate + ) + guard results.isCurrentConcreteQueryGeneration(generation) else { + throw CancellationError() + } + try Task.checkCancellation() + } catch { + await queryIndex.discardCandidates(id: id, through: generation) + clearCandidate(id: id, generation: generation) + throw error + } + + guard var beforeCommit = concreteQueryRegistrations[id], + let candidate = beforeCommit.candidate, + candidate.generation == generation, + candidate.projection?.sourceEpoch == querySourceEpoch else { + await queryIndex.discardCandidates(id: id, through: generation) + clearCandidate(id: id, generation: generation) + throw CancellationError() + } + beforeCommit.committing[generation] = candidate + beforeCommit.candidate = nil + concreteQueryRegistrations[id] = beforeCommit + + guard let committed = await queryIndex.commitReplacement( + id: id, + generation: generation + ) else { + clearCommitting(id: id, generation: generation) + throw CancellationError() + } + + guard var afterCommit = concreteQueryRegistrations[id], + var pendingPublication = afterCommit.committing.removeValue(forKey: generation) else { + preconditionFailure("Network query replacement lost committed publication state.") + } + pendingPublication.projection = coalesce( + pendingPublication.projection, + with: committed + ) + let publication = pendingPublication.projection ?? committed + let applied = results.applyNetworkQueryProjection( + publication, + query: query, + generation: generation, + isReplacement: true, + lookup: { id in self.requestForResult(id) } + ) + if generation > afterCommit.activeGeneration { + afterCommit.activeGeneration = generation + afterCommit.activeQuery = query + } + concreteQueryRegistrations[id] = afterCommit + if applied { + await queryIndex.acknowledge( + id: id, + generation: generation, + sourceEpoch: publication.sourceEpoch, + sequence: publication.sequence + ) + } + } + + /// Clears requests while remembering their protocol identities so late + /// terminal events from the cleared loads remain ignorable. + package nonisolated(nonsending) func clear() async { + clearedRequestIDs.formUnion(requestsByID.keys) + let reset = prepareRemoveAllRequests() + await finishQueryIndexReset(reset) + } + + /// Begins a new attachment epoch and immediately removes prior identities. + package func prepareResetForNewAttachment() -> QueryIndexReset { + clearedRequestIDs = [] + return prepareRemoveAllRequests() + } + + package nonisolated(nonsending) func finishQueryIndexReset(_ reset: QueryIndexReset) async { + let deliveries = await queryIndex.replace( + with: [], + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + await applyConcreteDeliveries(deliveries) + } + + @discardableResult + package func seedRequest( + requestID rawRequestID: String, + url: String, + method: String = "GET", + resourceTypeRawValue: String?, + requestHeaders: [String: String] = [:], + postData: String? = nil, + responseMIMEType: String, + responseStatus: Int, + responseStatusText: String, + responseHeaders: [String: String] = [:], + responseBody: String? = nil, + timestamp: Double, + encodedBodyLength: Int = 0, + modelContext: WebInspectorModelContext + ) -> NetworkRequest.ID { + let requestID = Network.Request.ID(rawRequestID) + let resourceType = resourceTypeRawValue.map(Network.ResourceType.init(rawValue:)) + let payload = Network.Request( + id: requestID, + url: url, + method: method, + headers: requestHeaders, + postData: postData + ) + let id = NetworkRequest.ID(requestID) + let request: NetworkRequest + let inserted: Bool + if let existing = requestsByID[id] { + request = existing + request.applyRequestWillBeSent( + request: payload, + resourceType: resourceType, + timestamp: timestamp + ) + inserted = false + } else { + request = NetworkRequest( + request: payload, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyResponse( + Network.Response( + url: url, + status: responseStatus, + statusText: responseStatusText, + mimeType: responseMIMEType, + headers: responseHeaders, + source: Network.Source(rawValue: "network"), + requestHeaders: requestHeaders + ), + resourceType: resourceType ?? .other, + timestamp: timestamp + 0.1 + ) + request.applyDataReceived( + dataLength: encodedBodyLength, + encodedDataLength: encodedBodyLength, + timestamp: timestamp + 0.11 + ) + request.finish( + timestamp: timestamp + 0.2, + sourceMapURL: nil, + metrics: Network.Metrics( + encodedDataLength: encodedBodyLength, + decodedBodyLength: encodedBodyLength + ) + ) + if let responseBody { + request.responseBody.load(Network.Body(data: responseBody, base64Encoded: false)) + } + queryIndexNeedsRebuild = true + if inserted { + collectionState.didInsertRequest() + } + return request.id + } + + package func seedResponseBody( + for requestID: NetworkRequest.ID, + body: String, + base64Encoded: Bool = false, + size: Int? = nil, + isTruncated: Bool = false + ) { + guard let request = requestsByID[requestID] else { + preconditionFailure("Cannot seed a response body for an unregistered NetworkRequest.") + } + request.responseBody.load(NetworkBody.Payload( + body: body, + base64Encoded: base64Encoded, + size: size, + isTruncated: isTruncated + )) + } + + package func indexWork(for reset: QueryIndexReset) -> IndexWork { + IndexWork( + index: queryIndex, + actions: [ + .replace( + inputs: [], + sequence: reset.sequence, + sourceEpoch: reset.sourceEpoch + ) + ] + ) + } + + /// Applies the semantic portion of one ordered feed event synchronously and + /// returns index-only work that can run without retaining this store or its + /// model context. + package func prepareModelEvent( + _ event: Network.Event, + modelContext: WebInspectorModelContext + ) -> IndexWork? { + let change: ModelChange? + switch event { + case let .requestWillBeSent(id, payload, resourceType, redirectResponse, timestamp): + change = prepareRequestWillBeSent( + id: id, + request: payload, + resourceType: resourceType, + redirectResponse: redirectResponse, + timestamp: timestamp, + modelContext: modelContext + ) + case let .responseReceived(id, response, resourceType, timestamp): + change = prepareResponseReceived( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + case let .dataReceived(id, dataLength, encodedDataLength, timestamp): + guard let request = request(for: id, method: "dataReceived") else { + return nil + } + request.applyDataReceived( + dataLength: dataLength, + encodedDataLength: encodedDataLength, + timestamp: timestamp + ) + change = ModelChange(request: request, inserted: false) + case let .loadingFinished(id, timestamp, sourceMapURL, metrics): + guard let request = request(for: id, method: "loadingFinished") else { + return nil + } + request.finish(timestamp: timestamp, sourceMapURL: sourceMapURL, metrics: metrics) + change = ModelChange(request: request, inserted: false) + case let .loadingFailed(id, errorText, canceled, timestamp): + guard let request = request(for: id, method: "loadingFailed") else { + return nil + } + request.fail(errorText: errorText, canceled: canceled, timestamp: timestamp) + change = ModelChange(request: request, inserted: false) + case let .webSocket(event): + change = prepareWebSocketEvent(event, modelContext: modelContext) + case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): + change = prepareRequestServedFromMemoryCache( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + case .unknown: + return nil + } + guard let change else { + return nil + } + if change.inserted { + collectionState.didInsertRequest() + } + var actions: [IndexWork.Action] = [] + if queryIndexNeedsRebuild { + queryIndexNeedsRebuild = false + actions.append(.replace( + inputs: currentRecordInputs(), + sequence: nextQueryIndexSequence(), + sourceEpoch: querySourceEpoch + )) + } + actions.append(.upsert( + input: recordInput(for: change.request), + sequence: nextQueryIndexSequence() + )) + return IndexWork(index: queryIndex, actions: actions) + } + + package func commit(_ result: IndexResult) -> IndexAcknowledgementWork? { + let entries = applyConcreteDeliveriesSynchronously(result.deliveries) + guard !entries.isEmpty else { + return nil + } + return IndexAcknowledgementWork(index: queryIndex, entries: entries) + } + + private func prepareRequestWillBeSent( + id proxyID: Network.Request.ID, + request payload: Network.Request, + resourceType: Network.ResourceType?, + redirectResponse: Network.Response?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) -> ModelChange? { + let id = NetworkRequest.ID(proxyID) + guard !clearedRequestIDs.contains(id) || redirectResponse == nil else { + return nil + } + clearedRequestIDs.remove(id) + if let request = requestsByID[id] { + if let redirectResponse, request.isActive { + request.applyRedirect( + to: payload, + redirectResponse: redirectResponse, + timestamp: timestamp, + resourceType: resourceType + ) + return ModelChange(request: request, inserted: false) + } + guard !request.isActive else { + return nil + } + request.applyRequestWillBeSent( + request: payload, + resourceType: resourceType, + timestamp: timestamp + ) + return ModelChange(request: request, inserted: false) + } + let request = NetworkRequest( + request: payload, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + return ModelChange(request: request, inserted: true) + } + + private func prepareResponseReceived( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) -> ModelChange? { + let id = NetworkRequest.ID(proxyID) + guard !clearedRequestIDs.contains(id) else { + return nil + } + let request: NetworkRequest + let inserted: Bool + if let existing = requestsByID[id] { + request = existing + inserted = false + } else { + guard let url = response.url else { + skipEvent("Network.responseReceived omitted response URL for an untracked request") + return nil + } + request = NetworkRequest( + request: Network.Request( + id: proxyID, + url: url, + method: "GET", + headers: response.requestHeaders ?? [:] + ), + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyResponse(response, resourceType: resourceType, timestamp: timestamp) + return ModelChange(request: request, inserted: inserted) + } + + private func prepareRequestServedFromMemoryCache( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) -> ModelChange? { + let id = NetworkRequest.ID(proxyID) + guard !clearedRequestIDs.contains(id) else { + return nil + } + let request: NetworkRequest + let inserted: Bool + if let existing = requestsByID[id] { + request = existing + inserted = false + } else { + guard let url = response.url else { + skipEvent("Network.requestServedFromMemoryCache omitted response URL for a new request") + return nil + } + request = NetworkRequest( + request: Network.Request( + id: proxyID, + url: url, + method: "GET", + headers: response.requestHeaders ?? [:] + ), + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) + return ModelChange(request: request, inserted: inserted) + } + + private func prepareWebSocketEvent( + _ event: Network.WebSocketEvent, + modelContext: WebInspectorModelContext + ) -> ModelChange? { + switch event { + case let .created(proxyID, url): + let id = NetworkRequest.ID(proxyID) + clearedRequestIDs.remove(id) + let request: NetworkRequest + let inserted: Bool + if let existing = requestsByID[id] { + request = existing + inserted = false + } else { + request = NetworkRequest( + request: Network.Request(id: proxyID, url: url, method: "GET"), + resourceType: .webSocket, + timestamp: nil, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyWebSocketCreated(url: url) + return ModelChange(request: request, inserted: inserted) + case let .handshakeRequest(id, payload, timestamp): + guard let request = request(for: id, method: "webSocketWillSendHandshakeRequest") else { + return nil + } + request.applyWebSocketHandshakeRequest(payload, timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case let .handshakeResponse(id, response, timestamp): + guard let request = request(for: id, method: "webSocketHandshakeResponseReceived") else { + return nil + } + request.applyWebSocketHandshakeResponse(response, timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case let .frameSent(id, frame, timestamp): + guard let request = request(for: id, method: "webSocketFrameSent") else { + return nil + } + request.appendWebSocketFrame(frame, direction: .sent, timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case let .frameReceived(id, frame, timestamp): + guard let request = request(for: id, method: "webSocketFrameReceived") else { + return nil + } + request.appendWebSocketFrame(frame, direction: .received, timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case let .error(id, message, timestamp): + guard let request = request(for: id, method: "webSocketFrameError") else { + return nil + } + request.appendWebSocketError(message, timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case let .closed(id, timestamp): + guard let request = request(for: id, method: "webSocketClosed") else { + return nil + } + request.closeWebSocket(timestamp: timestamp) + return ModelChange(request: request, inserted: false) + case .other: + return nil + } + } + + package nonisolated(nonsending) func apply( + _ event: Network.Event, + modelContext: WebInspectorModelContext + ) async { + switch event { + case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): + await applyRequestWillBeSent( + id: id, + request: request, + resourceType: resourceType, + redirectResponse: redirectResponse, + timestamp: timestamp, + modelContext: modelContext + ) + case let .responseReceived(id, response, resourceType, timestamp): + await applyResponseReceived( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + case let .dataReceived(id, dataLength, encodedDataLength, timestamp): + guard let request = request(for: id, method: "dataReceived") else { + return + } + request.applyDataReceived( + dataLength: dataLength, + encodedDataLength: encodedDataLength, + timestamp: timestamp + ) + await notifyRequestMutated(request, modelContext: modelContext) + case let .loadingFinished(id, timestamp, sourceMapURL, metrics): + guard let request = request(for: id, method: "loadingFinished") else { + return + } + request.finish(timestamp: timestamp, sourceMapURL: sourceMapURL, metrics: metrics) + await notifyRequestMutated(request, modelContext: modelContext) + case let .loadingFailed(id, errorText, canceled, timestamp): + guard let request = request(for: id, method: "loadingFailed") else { + return + } + request.fail(errorText: errorText, canceled: canceled, timestamp: timestamp) + await notifyRequestMutated(request, modelContext: modelContext) + case let .webSocket(event): + await apply(event, modelContext: modelContext) + case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): + await applyRequestServedFromMemoryCache( + id: id, + response: response, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + case .unknown: + break + } + } + + private nonisolated(nonsending) func applyRequestWillBeSent( + id proxyID: Network.Request.ID, + request payload: Network.Request, + resourceType: Network.ResourceType?, + redirectResponse: Network.Response?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) async { + let id = NetworkRequest.ID(proxyID) + guard clearedRequestIDs.contains(id) == false || redirectResponse == nil else { + return + } + clearedRequestIDs.remove(id) + let request: NetworkRequest + var inserted = false + var topologyMayHaveChanged = false + if let existing = requestsByID[id] { + request = existing + if let redirectResponse, existing.isActive { + request.applyRedirect( + to: payload, + redirectResponse: redirectResponse, + timestamp: timestamp, + resourceType: resourceType + ) + topologyMayHaveChanged = true + } else if existing.isActive == false { + request.applyRequestWillBeSent(request: payload, resourceType: resourceType, timestamp: timestamp) + topologyMayHaveChanged = true + } + } else { + request = NetworkRequest( + request: payload, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + if inserted { + await notifyRequestInserted(request, modelContext: modelContext) + } else if topologyMayHaveChanged { + await notifyRequestMutated(request, modelContext: modelContext) + } + } + + private nonisolated(nonsending) func applyRequestServedFromMemoryCache( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) async { + let id = NetworkRequest.ID(proxyID) + guard clearedRequestIDs.contains(id) == false else { + return + } + let request: NetworkRequest + if let existing = requestsByID[id] { + request = existing + } else { + guard let url = response.url else { + skipEvent("Network.requestServedFromMemoryCache omitted response URL for a new request") + return + } + let payload = Network.Request( + id: proxyID, + url: url, + method: "GET", + headers: response.requestHeaders ?? [:] + ) + request = NetworkRequest( + request: payload, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) + await notifyRequestInserted(request, modelContext: modelContext) + return + } + request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) + await notifyRequestMutated(request, modelContext: modelContext) + } + + private nonisolated(nonsending) func applyResponseReceived( + id proxyID: Network.Request.ID, + response: Network.Response, + resourceType: Network.ResourceType?, + timestamp: Double, + modelContext: WebInspectorModelContext + ) async { + let id = NetworkRequest.ID(proxyID) + guard clearedRequestIDs.contains(id) == false else { + return + } + let request: NetworkRequest + var inserted = false + if let existing = requestsByID[id] { + request = existing + } else { + guard let url = response.url else { + skipEvent("Network.responseReceived omitted response URL for an untracked request") + return + } + // WebKit's frontend creates a resource here when inspection starts + // after Network.requestWillBeSent. The response event has no method, + // so keep the same GET default WebKit uses when serializing such a + // resource later. + let payload = Network.Request( + id: proxyID, + url: url, + method: "GET", + headers: response.requestHeaders ?? [:] + ) + request = NetworkRequest( + request: payload, + resourceType: resourceType, + timestamp: timestamp, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyResponse(response, resourceType: resourceType, timestamp: timestamp) + if inserted { + await notifyRequestInserted(request, modelContext: modelContext) + } else { + await notifyRequestMutated(request, modelContext: modelContext) + } + } + + private nonisolated(nonsending) func apply( + _ event: Network.WebSocketEvent, + modelContext: WebInspectorModelContext + ) async { + switch event { + case let .created(id, url): + await applyWebSocketCreated( + id: id, + url: url, + modelContext: modelContext + ) + case let .handshakeRequest(id, payload, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketWillSendHandshakeRequest" + ) else { + return + } + networkRequest.applyWebSocketHandshakeRequest(payload, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case let .handshakeResponse(id, response, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketHandshakeResponseReceived" + ) else { + return + } + networkRequest.applyWebSocketHandshakeResponse(response, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case let .frameSent(id, frame, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameSent" + ) else { + return + } + networkRequest.appendWebSocketFrame(frame, direction: .sent, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case let .frameReceived(id, frame, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameReceived" + ) else { + return + } + networkRequest.appendWebSocketFrame(frame, direction: .received, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case let .error(id, message, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketFrameError" + ) else { + return + } + networkRequest.appendWebSocketError(message, timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case let .closed(id, timestamp): + guard let networkRequest = request( + for: id, + method: "webSocketClosed" + ) else { + return + } + networkRequest.closeWebSocket(timestamp: timestamp) + await notifyRequestMutated(networkRequest, modelContext: modelContext) + case .other: + break + } + } + + private nonisolated(nonsending) func applyWebSocketCreated( + id proxyID: Network.Request.ID, + url: String, + modelContext: WebInspectorModelContext + ) async { + let id = NetworkRequest.ID(proxyID) + clearedRequestIDs.remove(id) + let request: NetworkRequest + var inserted = false + if let existing = requestsByID[id] { + request = existing + } else { + let payload = Network.Request(id: proxyID, url: url, method: "GET") + request = NetworkRequest( + request: payload, + resourceType: .webSocket, + timestamp: nil, + modelContext: modelContext + ) + requestsByID[id] = request + appendRequestID(id) + inserted = true + } + request.applyWebSocketCreated(url: url) + if inserted { + await notifyRequestInserted(request, modelContext: modelContext) + } else { + await notifyRequestMutated(request, modelContext: modelContext) + } + } + + private func request( + for proxyID: Network.Request.ID, + method: String + ) -> NetworkRequest? { + let id = NetworkRequest.ID(proxyID) + guard let request = requestsByID[id] else { + if clearedRequestIDs.contains(id) == false { + skipEvent("Network.\(method) referenced an untracked request") + } + return nil + } + return request + } + + private func prepareRemoveAllRequests() -> QueryIndexReset { + for request in requestsByID.values { + request.invalidateResponseBodyFetch() + } + requestsByID = [:] + orderedRequestIDs = [] + orderIndicesByID = [:] + queryIndexNeedsRebuild = false + advanceQuerySourceEpoch() + let sequence = nextQueryIndexSequence() + collectionState.replaceCount(0) + let reset = QueryIndexReset( + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + publishEmptyConcreteQueryResults(for: reset) + return reset + } + + private func publishEmptyConcreteQueryResults(for reset: QueryIndexReset) { + pruneConcreteQueryRegistrations() + let projection = NetworkRequestIndex.QueryProjection( + sourceEpoch: reset.sourceEpoch, + sequence: reset.sequence, + snapshot: WebInspectorFetchedResultsSnapshot(), + reconfigureItemIDs: [] + ) + for registration in concreteQueryRegistrations.values { + registration.results.value?.applyNetworkQueryProjection( + projection, + query: registration.activeQuery, + generation: registration.activeGeneration, + isReplacement: false, + lookup: { _ in + preconditionFailure("An empty Network reset cannot resolve a model identity.") + } + ) + } + } + + private func advanceQuerySourceEpoch() { + precondition( + querySourceEpoch < UInt64.max, + "Network query source epoch overflowed." + ) + querySourceEpoch += 1 + } + + private func appendRequestID(_ id: NetworkRequest.ID) { + orderIndicesByID[id] = orderedRequestIDs.count + orderedRequestIDs.append(id) + } + + private func currentRecordInputs() -> [NetworkRequestRecordInput] { +#if DEBUG + performanceCounters.fullRecordProjectionCount += orderedRequestIDs.count +#endif + return orderedRequestIDs.enumerated().compactMap { index, id in + requestsByID[id].map { NetworkRequestRecordInput(request: $0, orderIndex: index) } + } + } + + private func recordInput(for request: NetworkRequest) -> NetworkRequestRecordInput { +#if DEBUG + performanceCounters.incrementalRecordProjectionCount += 1 +#endif + let orderIndex = orderIndicesByID[request.id] ?? orderedRequestIDs.count + return NetworkRequestRecordInput(request: request, orderIndex: orderIndex) + } + + private func isCurrent(_ request: NetworkRequest) -> Bool { + return requestsByID[request.id] === request + } + + private func nextQueryIndexSequence() -> UInt64 { + // The index drains a contiguous operation log, so every allocated + // sequence must be submitted exactly once to replace or upsert. + precondition( + queryIndexSequence < UInt64.max, + "NetworkRequestIndex mutation sequence overflowed." + ) + queryIndexSequence += 1 + return queryIndexSequence + } + + private nonisolated(nonsending) func syncQueryIndexIfNeeded() async { + guard queryIndexNeedsRebuild else { + return + } + queryIndexNeedsRebuild = false + let sequence = nextQueryIndexSequence() + let inputs = currentRecordInputs() + let deliveries = await queryIndex.replace( + with: inputs, + sequence: sequence, + sourceEpoch: querySourceEpoch + ) + await applyConcreteDeliveries(deliveries) + } + + private nonisolated(nonsending) func notifyRequestInserted( + _ request: NetworkRequest, + modelContext: WebInspectorModelContext + ) async { + collectionState.didInsertRequest() + await syncQueryIndexIfNeeded() + guard isCurrent(request) else { + return + } + let sequence = nextQueryIndexSequence() + let input = recordInput(for: request) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries) + guard isCurrent(request) else { + return + } + } + + private nonisolated(nonsending) func notifyRequestMutated( + _ request: NetworkRequest, + modelContext: WebInspectorModelContext + ) async { + await syncQueryIndexIfNeeded() + guard isCurrent(request) else { + return + } + let sequence = nextQueryIndexSequence() + let input = recordInput(for: request) + let deliveries = await queryIndex.upsert(input, sequence: sequence) + await applyConcreteDeliveries(deliveries) + guard isCurrent(request) else { + return + } + } + + private func allocateConcreteQueryRegistrationID() -> WebInspectorQueryRegistrationID { + precondition( + nextConcreteQueryRegistrationID < UInt64.max, + "Network concrete query registration identity overflowed." + ) + let id = WebInspectorQueryRegistrationID(rawValue: nextConcreteQueryRegistrationID) + nextConcreteQueryRegistrationID += 1 + return id + } + + private nonisolated(nonsending) func applyConcreteDeliveries(_ deliveries: [NetworkRequestIndex.QueryDelivery]) async { + let acknowledgements = applyConcreteDeliveriesSynchronously(deliveries) + await IndexAcknowledgementWork( + index: queryIndex, + entries: acknowledgements + ).run() + } + + private func applyConcreteDeliveriesSynchronously( + _ deliveries: [NetworkRequestIndex.QueryDelivery] + ) -> [IndexAcknowledgementWork.Entry] { + pruneConcreteQueryRegistrations() + var acknowledgements: [IndexAcknowledgementWork.Entry] = [] + for delivery in deliveries { + guard delivery.projection.sourceEpoch == querySourceEpoch else { + continue + } + let id = delivery.registrationID + if var initialization = initializingConcreteQueries[id], + initialization.generation == delivery.generation { + initialization.projection = coalesce( + initialization.projection, + with: delivery.projection + ) + initializingConcreteQueries[id] = initialization + continue + } + guard var registration = concreteQueryRegistrations[id] else { + continue + } + if delivery.generation == registration.activeGeneration { + guard let results = registration.results.value else { + concreteQueryRegistrations[id] = nil + continue + } + let applied = results.applyNetworkQueryProjection( + delivery.projection, + query: registration.activeQuery, + generation: delivery.generation, + isReplacement: false, + lookup: { id in self.requestForResult(id) } + ) + if applied { + acknowledgements.append(IndexAcknowledgementWork.Entry( + id: id, + generation: delivery.generation, + sourceEpoch: delivery.projection.sourceEpoch, + sequence: delivery.projection.sequence + )) + } + } else if var candidate = registration.candidate, + candidate.generation == delivery.generation { + candidate.projection = coalesce(candidate.projection, with: delivery.projection) + registration.candidate = candidate + } else if var committing = registration.committing[delivery.generation] { + committing.projection = coalesce( + committing.projection, + with: delivery.projection + ) + registration.committing[delivery.generation] = committing + } + concreteQueryRegistrations[id] = registration + } + return acknowledgements + } + + private func buffer( + _ projection: NetworkRequestIndex.QueryProjection, + for id: WebInspectorQueryRegistrationID, + generation: UInt64, + query: NetworkQuery, + destination: ConcreteQueryBufferDestination + ) { + guard var registration = concreteQueryRegistrations[id] else { + return + } + switch destination { + case .candidate: + guard var candidate = registration.candidate, + candidate.generation == generation, + candidate.query == query else { + return + } + candidate.projection = coalesce(candidate.projection, with: projection) + registration.candidate = candidate + case .committing: + guard var committing = registration.committing[generation], + committing.query == query else { + return + } + committing.projection = coalesce(committing.projection, with: projection) + registration.committing[generation] = committing + } + concreteQueryRegistrations[id] = registration + } + + private func clearCandidate( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + guard var registration = concreteQueryRegistrations[id], + registration.candidate?.generation == generation else { + return + } + registration.candidate = nil + concreteQueryRegistrations[id] = registration + } + + private func clearCommitting( + id: WebInspectorQueryRegistrationID, + generation: UInt64 + ) { + guard var registration = concreteQueryRegistrations[id] else { + return + } + registration.committing[generation] = nil + concreteQueryRegistrations[id] = registration + } + + private func coalesce( + _ current: NetworkRequestIndex.QueryProjection?, + with incoming: NetworkRequestIndex.QueryProjection + ) -> NetworkRequestIndex.QueryProjection { + guard let current else { + return incoming + } + let incomingIsNewer = incoming.sourceEpoch > current.sourceEpoch + || (incoming.sourceEpoch == current.sourceEpoch && incoming.sequence > current.sequence) + let newest = incomingIsNewer ? incoming : current + let reconfigureItemIDs = current.reconfigureItemIDs + .union(incoming.reconfigureItemIDs) + .intersection(newest.snapshot.itemIDs) + return NetworkRequestIndex.QueryProjection( + sourceEpoch: newest.sourceEpoch, + sequence: newest.sequence, + snapshot: newest.snapshot, + reconfigureItemIDs: reconfigureItemIDs + ) + } + + private func pruneConcreteQueryRegistrations() { + concreteQueryRegistrations = concreteQueryRegistrations.filter { _, registration in + registration.results.value != nil + } + } + +#if DEBUG + package nonisolated(nonsending) func concreteQueryRegistrationCountForTesting() async -> Int { + return await queryIndex.queryRegistrationCountForTesting() + } +#endif + + private func requestForResult(_ id: NetworkRequest.ID) -> NetworkRequest? { +#if DEBUG + performanceCounters.resultIdentityLookupCount += 1 +#endif + return requestsByID[id] + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } +} diff --git a/Sources/WebInspectorDataKit/RuntimeContext.swift b/Sources/WebInspectorDataKit/RuntimeContext.swift index a9a28492..1c35eedf 100644 --- a/Sources/WebInspectorDataKit/RuntimeContext.swift +++ b/Sources/WebInspectorDataKit/RuntimeContext.swift @@ -26,14 +26,11 @@ public final class RuntimeContext: WebInspectorPersistentModel { /// The kind of execution context reported by WebKit. public private(set) var kind: Runtime.ContextKind - @ObservationIgnored weak var modelContext: WebInspectorContext? - - init(context: Runtime.ExecutionContext, modelContext: WebInspectorContext) { + init(context: Runtime.ExecutionContext) { id = ID(context.id) name = context.name frameID = context.frameID kind = context.kind - self.modelContext = modelContext } func update(from context: Runtime.ExecutionContext) { diff --git a/Sources/WebInspectorDataKit/RuntimeObject.swift b/Sources/WebInspectorDataKit/RuntimeObject.swift index 8f2c5157..ef234efe 100644 --- a/Sources/WebInspectorDataKit/RuntimeObject.swift +++ b/Sources/WebInspectorDataKit/RuntimeObject.swift @@ -81,7 +81,6 @@ public final class RuntimeObject: WebInspectorPersistentModel { /// A compact preview for the value, if included in the payload. public private(set) var preview: Runtime.ObjectPreview? - @ObservationIgnored weak var modelContext: WebInspectorContext? @ObservationIgnored var proxyID: Runtime.RemoteObject.ID? /// A Boolean value indicating whether this object has a live remote handle. @@ -91,8 +90,7 @@ public final class RuntimeObject: WebInspectorPersistentModel { init( id: ID, - remoteObject: Runtime.RemoteObject, - modelContext: WebInspectorContext + remoteObject: Runtime.RemoteObject ) { self.id = id kind = remoteObject.kind @@ -103,29 +101,6 @@ public final class RuntimeObject: WebInspectorPersistentModel { size = remoteObject.size preview = remoteObject.preview proxyID = remoteObject.id - self.modelContext = modelContext - } - - /// Requests own property values for this object. - public func properties(isolation: isolated (any Actor) = #isolation) async throws -> [Property] { - guard canRequestProperties else { - return [] - } - guard let modelContext else { - throw WebInspectorProxyError.disconnected("RuntimeObject is not registered in a WebInspectorContext.") - } - return try await modelContext.properties(for: self, isolation: isolation) - } - - /// Requests collection entries for this object. - public func collectionEntries(isolation: isolated (any Actor) = #isolation) async throws -> [Entry] { - guard canRequestProperties else { - return [] - } - guard let modelContext else { - throw WebInspectorProxyError.disconnected("RuntimeObject is not registered in a WebInspectorContext.") - } - return try await modelContext.collectionEntries(for: self, isolation: isolation) } func update(from remoteObject: Runtime.RemoteObject) { diff --git a/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift b/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift new file mode 100644 index 00000000..5b8ae090 --- /dev/null +++ b/Sources/WebInspectorDataKit/RuntimeObjectGroup.swift @@ -0,0 +1,108 @@ +import WebInspectorProxyKit + +/// Preserves both a failed scoped operation and its independent cleanup error. +public struct WebInspectorRuntimeScopeError: Error { + public let operationError: any Error + public let cleanupError: any Error + + public init(operationError: any Error, cleanupError: any Error) { + self.operationError = operationError + self.cleanupError = cleanupError + } +} + +public typealias RuntimeProperty = RuntimeObject.Property +public typealias RuntimeObjectPreview = Runtime.ObjectPreview + +/// An explicit binding-scoped owner of Runtime remote object identities. +public final class RuntimeObjectGroup { + package struct ID: Hashable { + package let rawValue: UInt64 + } + + package weak var modelContext: WebInspectorModelContext? + package let id: ID + package let target: WebInspectorTarget + package let wireGroup: Runtime.ObjectGroup + package let attachmentGeneration: UInt64 + package let pageGeneration: WebInspectorPage.Generation + public private(set) var isClosed: Bool + + package init( + modelContext: WebInspectorModelContext, + id: ID, + target: WebInspectorTarget, + wireGroup: Runtime.ObjectGroup, + attachmentGeneration: UInt64, + pageGeneration: WebInspectorPage.Generation + ) { + self.modelContext = modelContext + self.id = id + self.target = target + self.wireGroup = wireGroup + self.attachmentGeneration = attachmentGeneration + self.pageGeneration = pageGeneration + isClosed = false + } + + public nonisolated(nonsending) func evaluate( + _ expression: String, + in context: RuntimeContext? = nil + ) async throws -> RuntimeEvaluation { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { + throw WebInspectorModelError.staleModel + } + return try await modelContext.evaluate( + expression, + in: context, + objectGroup: self + ) + } + + public nonisolated(nonsending) func properties( + of object: RuntimeObject, + ownProperties: Bool = true + ) async throws -> [RuntimeProperty] { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { + throw WebInspectorModelError.staleModel + } + return try await modelContext.properties( + of: object, + ownProperties: ownProperties, + objectGroup: self + ) + } + + public nonisolated(nonsending) func preview( + of object: RuntimeObject + ) async throws -> RuntimeObjectPreview { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { + throw WebInspectorModelError.staleModel + } + return try await modelContext.preview(of: object, objectGroup: self) + } + + public nonisolated(nonsending) func close() async throws { + guard let modelContext else { + throw WebInspectorModelError.staleModel + } + modelContext.preconditionOwnerIsolation() + guard !isClosed else { + return + } + try await modelContext.close(objectGroup: self) + isClosed = true + } +} diff --git a/Sources/WebInspectorDataKit/RuntimeStateStore.swift b/Sources/WebInspectorDataKit/RuntimeStateStore.swift new file mode 100644 index 00000000..516cbdde --- /dev/null +++ b/Sources/WebInspectorDataKit/RuntimeStateStore.swift @@ -0,0 +1,393 @@ +import WebInspectorProxyKit + +/// Owns Runtime execution-context identity, selection, remote-object identity, +/// and local object membership. +/// +/// `WebInspectorModelContext` remains the attachment and transport coordinator. It +/// validates command inputs through this store, performs protocol I/O, and +/// returns replies for post-suspension validation and materialization. The +/// store never retains an actor token or starts a task. +package final class RuntimeStateStore { + package struct EvaluationBinding { + package let executionContextID: Runtime.ExecutionContext.ID? + + fileprivate let executionContext: RuntimeContext? + fileprivate let defaultExecutionGeneration: UInt64 + } + + package struct ObjectBinding { + package let remoteID: Runtime.RemoteObject.ID + + fileprivate let object: RuntimeObject + } + + private enum ObjectOwner: Hashable { + case group(RuntimeObjectGroup.ID) + case console + } + + private struct ObjectRecord { + let object: RuntimeObject + var owners: Set + } + + private var contextsByID: [RuntimeContext.ID: RuntimeContext] + private var orderedContextIDs: [RuntimeContext.ID] + private var selectedContextID: RuntimeContext.ID? + private var objectsByID: [RuntimeObject.ID: ObjectRecord] + private var nextSyntheticObjectOrdinal: Int + private var defaultExecutionGeneration: UInt64 + private var nextGroupOrdinal: UInt64 + private var activeGroupIDs: Set + + package init() { + contextsByID = [:] + orderedContextIDs = [] + selectedContextID = nil + objectsByID = [:] + nextSyntheticObjectOrdinal = 0 + defaultExecutionGeneration = 0 + nextGroupOrdinal = 0 + activeGroupIDs = [] + } + + package var executionContexts: [RuntimeContext] { + orderedContextIDs.map { id in + guard let context = contextsByID[id] else { + preconditionFailure("RuntimeStateStore context order referenced an unregistered identity.") + } + return context + } + } + + package var selectedContext: RuntimeContext? { + guard let selectedContextID else { + return nil + } + guard let context = contextsByID[selectedContextID] else { + preconditionFailure("RuntimeStateStore selection referenced an unregistered identity.") + } + return context + } + + package func select( + _ context: RuntimeContext? + ) { + guard let context else { + selectedContextID = nil + return + } + guard contextsByID[context.id] === context else { + preconditionFailure("RuntimeContext is not registered in this WebInspectorModelContext.") + } + selectedContextID = context.id + } + + package func evaluationBinding( + for context: RuntimeContext? + ) throws -> EvaluationBinding { + let executionContext: RuntimeContext? + if let context { + try requireRegisteredContext(context) + executionContext = context + } else { + executionContext = selectedContext + } + return EvaluationBinding( + executionContextID: executionContext?.id.proxyID, + executionContext: executionContext, + defaultExecutionGeneration: defaultExecutionGeneration + ) + } + + package func finishEvaluation( + _ result: Runtime.EvaluationResult, + binding: EvaluationBinding, + groupID: RuntimeObjectGroup.ID + ) throws -> RuntimeEvaluation { + try requireActiveGroup(groupID) + if let executionContext = binding.executionContext { + try requireRegisteredContext(executionContext) + } else if binding.defaultExecutionGeneration != defaultExecutionGeneration { + throw WebInspectorProxyError.disconnected( + "Runtime evaluation target is no longer current in this WebInspectorModelContext." + ) + } + return RuntimeEvaluation( + object: register(result.object, owner: .group(groupID)), + isException: result.wasThrown + ) + } + + package func objectBinding( + for object: RuntimeObject, + groupID: RuntimeObjectGroup.ID + ) throws -> ObjectBinding? { + try requireActiveGroup(groupID) + let object = try requireRegisteredObject(object, owner: .group(groupID)) + return object.proxyID.map { ObjectBinding(remoteID: $0, object: object) } + } + + package func finishProperties( + _ descriptors: [Runtime.PropertyDescriptor], + binding: ObjectBinding, + groupID: RuntimeObjectGroup.ID + ) throws -> [RuntimeObject.Property] { + try requireActiveGroup(groupID) + try requireRegisteredObject(binding.object, owner: .group(groupID)) + return descriptors.map { descriptor in + let remoteValue = descriptor.value + let childObject = remoteValue.flatMap { value in + value.id == nil ? nil : register(value, owner: .group(groupID)) + } + return RuntimeObject.Property( + name: descriptor.name, + value: remoteValue.flatMap(Self.valueText), + object: childObject + ) + } + } + + package func finishCollectionEntries( + _ entries: [Runtime.CollectionEntry], + binding: ObjectBinding, + groupID: RuntimeObjectGroup.ID + ) throws -> [RuntimeObject.Entry] { + try requireActiveGroup(groupID) + try requireRegisteredObject(binding.object, owner: .group(groupID)) + return entries.map { entry in + RuntimeObject.Entry( + key: entry.key.map { register($0, owner: .group(groupID)) }, + value: register(entry.value, owner: .group(groupID)) + ) + } + } + + package func registerConsoleParameter( + _ payload: Runtime.RemoteObject + ) -> RuntimeObject { + return register(payload, owner: .console) + } + + package func removeConsoleOwnership( + from objects: [RuntimeObject] + ) { + for object in objects { + guard var record = objectsByID[object.id], record.object === object else { + continue + } + record.owners.remove(.console) + if record.owners.isEmpty { + objectsByID.removeValue(forKey: object.id) + } else { + objectsByID[object.id] = record + } + } + } + + package func removeAllConsoleOwnership( + ) { + for (id, var record) in objectsByID.map({ ($0.key, $0.value) }) { + record.owners.remove(.console) + if record.owners.isEmpty { + objectsByID.removeValue(forKey: id) + } else { + objectsByID[id] = record + } + } + } + + package func apply( + _ event: Runtime.Event, + sourceTargetID: WebInspectorTarget.ID?, + isCurrentPageTarget: Bool + ) { + switch event { + case let .executionContextCreated(context): + applyExecutionContextCreated(context) + case let .executionContextDestroyed(id): + applyExecutionContextDestroyed(id) + case .executionContextsCleared: + if isCurrentPageTarget { + reset() + } else if let sourceTargetID { + clear(targetID: sourceTargetID) + } + case .unknown: + break + } + } + + package func reset() { + contextsByID = [:] + orderedContextIDs = [] + selectedContextID = nil + objectsByID = [:] + activeGroupIDs = [] + advanceDefaultExecutionGeneration() + } + + package func createGroupID() -> RuntimeObjectGroup.ID { + precondition(nextGroupOrdinal < UInt64.max, "Runtime object-group identity overflowed.") + let id = RuntimeObjectGroup.ID(rawValue: nextGroupOrdinal) + nextGroupOrdinal += 1 + precondition(activeGroupIDs.insert(id).inserted) + return id + } + + package func isActiveGroup(_ id: RuntimeObjectGroup.ID) -> Bool { + activeGroupIDs.contains(id) + } + + package func invalidateGroup(_ id: RuntimeObjectGroup.ID) { + guard activeGroupIDs.remove(id) != nil else { + return + } + for (objectID, var record) in objectsByID.map({ ($0.key, $0.value) }) { + record.owners.remove(.group(id)) + if record.owners.isEmpty { + objectsByID.removeValue(forKey: objectID) + } else { + objectsByID[objectID] = record + } + } + } + + private func applyExecutionContextCreated(_ payload: Runtime.ExecutionContext) { + let id = RuntimeContext.ID(payload.id) + if let context = contextsByID[id] { + context.update(from: payload) + } else { + contextsByID[id] = RuntimeContext(context: payload) + orderedContextIDs.append(id) + } + if selectedContextID == nil { + selectedContextID = id + } + } + + private func applyExecutionContextDestroyed(_ proxyID: Runtime.ExecutionContext.ID) { + let id = RuntimeContext.ID(proxyID) + guard contextsByID.removeValue(forKey: id) != nil else { + skipEvent("Runtime.executionContextDestroyed referenced an untracked context") + return + } + orderedContextIDs.removeAll { $0 == id } + if selectedContextID == id { + selectedContextID = orderedContextIDs.first + } + } + + private func clear(targetID: WebInspectorTarget.ID) { + let removedContextIDs = Set(contextsByID.keys.filter { id in + id.proxyID.targetScopeRawValue == targetID.rawValue + }) + for id in removedContextIDs { + contextsByID.removeValue(forKey: id) + } + orderedContextIDs.removeAll { removedContextIDs.contains($0) } + if let selectedContextID, removedContextIDs.contains(selectedContextID) { + self.selectedContextID = orderedContextIDs.first + } + + let removedObjectIDs = objectsByID.compactMap { id, record in + record.object.proxyID?.targetScopeRawValue == targetID.rawValue ? id : nil + } + for id in removedObjectIDs { + objectsByID.removeValue(forKey: id) + } + } + + private func register( + _ payload: Runtime.RemoteObject, + owner: ObjectOwner + ) -> RuntimeObject { + let id: RuntimeObject.ID + if let proxyID = payload.id { + id = RuntimeObject.ID(remote: proxyID) + } else { + precondition( + nextSyntheticObjectOrdinal < Int.max, + "RuntimeObject synthetic identity ordinal overflowed." + ) + id = RuntimeObject.ID(synthetic: nextSyntheticObjectOrdinal) + nextSyntheticObjectOrdinal += 1 + } + + if var record = objectsByID[id] { + record.object.update(from: payload) + record.owners.insert(owner) + objectsByID[id] = record + return record.object + } + + let object = RuntimeObject(id: id, remoteObject: payload) + objectsByID[id] = ObjectRecord(object: object, owners: [owner]) + return object + } + + @discardableResult + private func requireRegisteredContext(_ context: RuntimeContext) throws -> RuntimeContext { + guard contextsByID[context.id] === context else { + throw WebInspectorProxyError.disconnected( + "RuntimeContext is not registered in this WebInspectorModelContext." + ) + } + return context + } + + @discardableResult + private func requireRegisteredObject( + _ object: RuntimeObject, + owner: ObjectOwner? = nil + ) throws -> RuntimeObject { + guard let record = objectsByID[object.id], + record.object === object, + owner.map({ record.owners.contains($0) }) ?? true else { + throw WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorModelContext." + ) + } + return object + } + + private func requireActiveGroup(_ id: RuntimeObjectGroup.ID) throws { + guard activeGroupIDs.contains(id) else { + throw WebInspectorModelError.staleModel + } + } + + private func advanceDefaultExecutionGeneration() { + precondition( + defaultExecutionGeneration < UInt64.max, + "Runtime default execution generation overflowed." + ) + defaultExecutionGeneration += 1 + } + + private static func valueText(for object: Runtime.RemoteObject) -> String? { + if let description = object.description { + return description + } + guard let value = object.value else { + return nil + } + switch value { + case let .string(value): + return value + case let .number(value): + return String(value) + case let .bool(value): + return String(value) + case .null: + return "null" + case .array, + .object: + return nil + } + } + + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } +} diff --git a/Sources/WebInspectorDataKit/WebInspectorContainer.swift b/Sources/WebInspectorDataKit/WebInspectorContainer.swift deleted file mode 100644 index e1fbee09..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorContainer.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import WebKit -import WebInspectorProxyKit - -/// A DataKit inspection container attached to one proxy connection. -/// -/// `WebInspectorContainer` owns the underlying `WebInspectorProxy` and vends -/// context objects that preserve DOM, Network, Console, Runtime, and CSS model -/// identity. -/// -/// Example: -/// -/// ```swift -/// let container = try await WebInspectorContainer(attachingTo: webView) -/// let context = container.mainContext -/// -/// let tree = context.dom.treeController() -/// render(tree.snapshot) -/// -/// await container.close() -/// ``` -public final class WebInspectorContainer: @unchecked Sendable { - let proxy: WebInspectorProxy - let domainEnablement: WebInspectorDomainEnablementRegistry - - @MainActor private var _mainContext: WebInspectorContext? - - /// The main-actor context for UIKit/AppKit clients. - /// - /// The context is created lazily and starts observing the inspected page - /// when first accessed. - @MainActor public var mainContext: WebInspectorContext { - if let context = _mainContext { - return context - } - let context = WebInspectorContext(self, isolation: MainActor.shared) - _mainContext = context - context.start() - return context - } - - /// Creates a container from an existing proxy connection. - public init(proxy: WebInspectorProxy) { - self.proxy = proxy - domainEnablement = WebInspectorDomainEnablementRegistry() - } - - /// Attaches to a web view and creates a container for the resulting proxy. - @MainActor - public convenience init( - attachingTo webView: WKWebView, - configuration: WebInspectorProxy.Configuration = .init() - ) async throws { - let proxy = try await WebInspectorProxy(attachingTo: webView, configuration: configuration) - self.init(proxy: proxy) - } - - /// Stops the main context and closes the underlying proxy connection. - public func close() async { - await stopMainContext() - await proxy.close() - } - - @MainActor - private func stopMainContext() async { - await _mainContext?.stop() - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorContext.swift b/Sources/WebInspectorDataKit/WebInspectorContext.swift deleted file mode 100644 index b23fccc4..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorContext.swift +++ /dev/null @@ -1,4327 +0,0 @@ -import Foundation -import WebInspectorProxyKit - -/// The identity-preserving model context for an inspected page. -/// -/// A context owns observable DOM, Network, Console, Runtime, and CSS models. -/// It is isolated to the actor passed at initialization; callers must use the -/// same actor when reading or mutating context-owned state. -public final class WebInspectorContext { - package struct DOMUndoRedoCommands { - private weak var context: WebInspectorContext? - private let target: WebInspectorTarget? - private let documentGeneration: Int - - fileprivate init(context: WebInspectorContext, target: WebInspectorTarget?, documentGeneration: Int) { - self.context = context - self.target = target - self.documentGeneration = documentGeneration - } - - package func undo(isolation: isolated (any Actor) = #isolation) async throws { - try await undoRedoTarget(isolation: isolation).dom.undo() - } - - package func redo(isolation: isolated (any Actor) = #isolation) async throws { - try await undoRedoTarget(isolation: isolation).dom.redo() - } - - private func undoRedoTarget(isolation: isolated (any Actor)) throws -> WebInspectorTarget { - guard let context else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit context was released before DOM undo/redo.") - } - context.requireOwner(isolation) - guard context.domDocumentGeneration == documentGeneration else { - throw WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.") - } - if let target { - return target - } - if let target = context.domEditHistoryTarget { - return target - } - guard context.didInvalidateDOMEditHistoryTarget == false else { - throw WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.") - } - return try context.currentPageOrThrow() - } - } - - package struct DOMDeletionPartialFailure: Error { - package let deletedNodeCount: Int - package let underlyingError: any Error - - package init(deletedNodeCount: Int, underlyingError: any Error) { - self.deletedNodeCount = deletedNodeCount - self.underlyingError = underlyingError - } - } - - private enum RuntimeObjectOwner: Hashable { - case client - case console - } - - private typealias LoadedDOMDocument = (node: DOM.Node, generation: Int) - -#if DEBUG - private struct EventPumpAppliedWaiterForTesting { - var minimumSequence: UInt64 - var continuation: CheckedContinuation - } -#endif - - /// The attachment state of a context. - public enum State: Equatable, Sendable { - /// The context is enabling domains and loading initial state. - case attaching - - /// The context is attached and has started observing the page. - case attached - - /// The context has been detached. - case detached - - /// The context failed with an inspector error. - case failed(WebInspectorProxyError) - } - - /// A compact status value suitable for UI binding. - public struct Status: Equatable, Sendable { - /// The current attachment state. - public let state: State - - /// The currently selected DOM node identity. - public let selectedNodeID: DOMNode.ID? - - /// A Boolean value indicating whether WebKit inspect mode is enabled. - public let isElementPickerEnabled: Bool - } - - private(set) weak var container: WebInspectorContainer? - private let proxy: WebInspectorProxy - private let domainEnablement: WebInspectorDomainEnablementRegistry - private let owner: any Actor - /// The current attachment state. - public private(set) var state: State - - /// The terminal teardown error, if the context failed or detached because - /// of an inspector error. - public private(set) var teardownError: WebInspectorProxyError? - - /// The current root DOM node, if a document is loaded. - public private(set) var rootNode: DOMNode? - - /// The currently selected DOM node. - public private(set) var selectedNode: DOMNode? - - /// A Boolean value indicating whether WebKit inspect mode is enabled. - public private(set) var isElementPickerEnabled: Bool - - /// Runtime execution contexts known to the current page. - public private(set) var executionContexts: [RuntimeContext] - - /// The selected Runtime execution context. - public private(set) var selectedContext: RuntimeContext? - - private var currentPage: WebInspectorTarget? - private var currentPageGeneration: Int - private var domDocumentGeneration: Int - private var startupTask: Task? - private var currentPageRetargetTask: Task? - private var currentPageCleanupTask: Task? - private var domEditHistoryTarget: WebInspectorTarget? - private var didInvalidateDOMEditHistoryTarget: Bool - private var documentReloadTask: Task? - private var inspectResolutionTask: Task? - private var inspectedNodeHighlightTask: Task? - private var frameDocumentLoadTasks: [WebInspectorTarget.ID: Task] - private var styleRefreshTask: Task? - private var styleRefreshGeneration: Int - private var isStyleHydrationActive: Bool - private var styleToggleTasks: [CSSStyleProperty.ID: Task] - private var eventPumps: [WebInspectorEventPump] -#if DEBUG - private var eventPumpAppliedSequenceForTestingStorage: UInt64 - private var eventPumpAppliedWaitersForTesting: [UInt64: EventPumpAppliedWaiterForTesting] - private var nextEventPumpAppliedWaiterIDForTesting: UInt64 -#endif - private var inspectorTrackingTarget: WebInspectorTarget? - private var networkTrackingTarget: WebInspectorTarget? - private var runtimeTrackingTarget: WebInspectorTarget? - private var consoleTrackingTarget: WebInspectorTarget? - private var nodesByID: [DOMNode.ID: DOMNode] - private var frameDocumentProjectionIndex: FrameDocumentProjectionIndex - private var treeStates: [WeakDOMTreeState] - private let statusRelay: WebInspectorAsyncStreamRelay - private var requestsByID: [NetworkRequest.ID: NetworkRequest] - private var orderedRequestIDs: [NetworkRequest.ID] - private var networkRequestOrderIndicesByID: [NetworkRequest.ID: Int] - private var clearedNetworkRequestIDs: Set - private let networkRequestIndex: NetworkRequestIndex - private var networkRequestIndexSequence: UInt64 - private var networkRequestIndexNeedsRebuild: Bool - private let networkCollectionState: NetworkRequestCollectionState - private var networkFetchedResults: [WeakWebInspectorFetchedResults] - private var consoleMessagesByID: [ConsoleMessage.ID: ConsoleMessage] - private var orderedConsoleMessageIDs: [ConsoleMessage.ID] - private var lastConsoleMessageID: ConsoleMessage.ID? - private var lastConsoleMessageIDByTargetID: [WebInspectorTarget.ID: ConsoleMessage.ID] - private var nextConsoleMessageOrdinal: Int - private var consoleFetchedResults: [WeakWebInspectorFetchedResults] - private var runtimeContextsByID: [RuntimeContext.ID: RuntimeContext] - private var orderedRuntimeContextIDs: [RuntimeContext.ID] - private var runtimeObjectsByID: [RuntimeObject.ID: RuntimeObject] - private var runtimeObjectIDsByProxyID: [Runtime.RemoteObject.ID: RuntimeObject.ID] - private var runtimeObjectOwnersByID: [RuntimeObject.ID: Set] - private var nextRuntimeObjectOrdinal: Int - private var pendingInspectedNodeID: DOMNode.ID? - private var consoleObjectGroupReleaseTasks: [WebInspectorTarget.ID: Task] - private var pageHighlightDocumentGeneration: Int? - - /// Creates a context owned by the supplied actor. - public init(_ container: WebInspectorContainer, isolation: isolated (any Actor)) { - self.container = container - proxy = container.proxy - domainEnablement = container.domainEnablement - owner = isolation - state = .attaching - teardownError = nil - rootNode = nil - selectedNode = nil - domEditHistoryTarget = nil - didInvalidateDOMEditHistoryTarget = false - isElementPickerEnabled = false - executionContexts = [] - selectedContext = nil - currentPage = nil - currentPageGeneration = 0 - domDocumentGeneration = 0 - startupTask = nil - currentPageRetargetTask = nil - currentPageCleanupTask = nil - domEditHistoryTarget = nil - documentReloadTask = nil - inspectResolutionTask = nil - inspectedNodeHighlightTask = nil - frameDocumentLoadTasks = [:] - styleRefreshTask = nil - styleRefreshGeneration = 0 - isStyleHydrationActive = false - styleToggleTasks = [:] - eventPumps = [] -#if DEBUG - eventPumpAppliedSequenceForTestingStorage = 0 - eventPumpAppliedWaitersForTesting = [:] - nextEventPumpAppliedWaiterIDForTesting = 0 -#endif - inspectorTrackingTarget = nil - networkTrackingTarget = nil - runtimeTrackingTarget = nil - consoleTrackingTarget = nil - nodesByID = [:] - frameDocumentProjectionIndex = FrameDocumentProjectionIndex() - treeStates = [] - statusRelay = WebInspectorAsyncStreamRelay() - requestsByID = [:] - orderedRequestIDs = [] - networkRequestOrderIndicesByID = [:] - clearedNetworkRequestIDs = [] - networkRequestIndex = NetworkRequestIndex() - networkRequestIndexSequence = 0 - networkRequestIndexNeedsRebuild = false - networkCollectionState = NetworkRequestCollectionState() - networkFetchedResults = [] - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] - nextConsoleMessageOrdinal = 0 - consoleFetchedResults = [] - runtimeContextsByID = [:] - orderedRuntimeContextIDs = [] - runtimeObjectsByID = [:] - runtimeObjectIDsByProxyID = [:] - runtimeObjectOwnersByID = [:] - nextRuntimeObjectOrdinal = 0 - pendingInspectedNodeID = nil - consoleObjectGroupReleaseTasks = [:] - pageHighlightDocumentGeneration = nil - WebInspectorDataKitLog.debug("context state=\(state.logDescription)") - } - - package static func preview(isolation: isolated (any Actor)) -> WebInspectorContext { - let container = WebInspectorContainer(proxy: WebInspectorProxy()) - let context = WebInspectorContext(container, isolation: isolation) - context.state = .attached - return context - } - - package static func detached(isolation: isolated (any Actor)) -> WebInspectorContext { - let container = WebInspectorContainer(proxy: WebInspectorProxy()) - let context = WebInspectorContext(container, isolation: isolation) - context.state = .detached - return context - } - - deinit { - startupTask?.cancel() - currentPageRetargetTask?.cancel() - currentPageCleanupTask?.cancel() - documentReloadTask?.cancel() - inspectResolutionTask?.cancel() - inspectedNodeHighlightTask?.cancel() - cancelFrameDocumentLoadTasks() - styleRefreshTask?.cancel() - for task in styleToggleTasks.values { - task.cancel() - } - stopEventPumps() - for task in consoleObjectGroupReleaseTasks.values { - task.cancel() - } - resolveEventPumpAppliedWaitersForTesting(result: false) - } - - /// Starts observing the inspected page and rebuilding DataKit models. - public func start(isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - let previousStartupTask = startupTask - let previousCurrentPageCleanupTask = currentPageCleanupTask - previousStartupTask?.cancel() - currentPageRetargetTask?.cancel() - currentPageRetargetTask = nil - state = .attaching - notifyStatusChanged() - teardownError = nil - resetNetworkModelsForNewAttachment() - startupTask = Task { [weak self, previousStartupTask, previousCurrentPageCleanupTask] in - _ = isolation - await previousStartupTask?.value - await previousCurrentPageCleanupTask?.value - guard Task.isCancelled == false else { - return - } - guard let self else { - return - } - await self.startup(isolation: isolation) - } - } - - package var status: Status { - Status( - state: state, - selectedNodeID: selectedNode?.id, - isElementPickerEnabled: isElementPickerEnabled - ) - } - - package var statusUpdates: AsyncStream { - statusRelay.makeStream(initialElement: status) - } - - /// Returns the registered DOM node for an identifier. - public func node(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) -> DOMNode? { - requireOwner(isolation) - return nodesByID[id] - } - - package func requiredNode(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> DOMNode { - requireOwner(isolation) - guard let node = nodesByID[id] else { - throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") - } - return node - } - - /// Returns the registered Network request for an identifier. - public func registeredRequest( - for id: NetworkRequest.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - requireOwner(isolation) - return requestsByID[id] - } - - package var networkRequestsCollectionState: NetworkRequestCollectionState { - networkCollectionState - } - - package func registeredRequest( - forProxyID id: Network.Request.ID, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest? { - requireOwner(isolation) - return requestsByID[NetworkRequest.ID(id)] - } - - /// Clears retained Network requests and emits reset transactions. - public func clearNetworkRequests(isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - clearNetworkRequests() - } - - /// Returns the registered Console message for an identifier. - public func registeredMessage( - for id: ConsoleMessage.ID, - isolation: isolated (any Actor) = #isolation - ) -> ConsoleMessage? { - requireOwner(isolation) - return consoleMessagesByID[id] - } - - /// Selects a DOM node and reveals it in registered tree controllers. - public func select(_ node: DOMNode?, isolation: isolated (any Actor) = #isolation) { - select(node, reveal: .selectAndScroll, isolation: isolation) - } - - private func select( - _ node: DOMNode?, - reveal: DOMRevealPolicy, - isolation: isolated (any Actor) - ) { - requireOwner(isolation) - if let node, nodesByID[node.id] !== node { - preconditionFailure("DOMNode is not registered in this WebInspectorContext.") - } - pendingInspectedNodeID = nil - inspectResolutionTask?.cancel() - inspectResolutionTask = nil - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - selectedNode = node - notifyDOMTreeSelectionChanged(node, reveal: reveal, isolation: isolation) - notifyStatusChanged() - refreshSelectedStyles(isolation: isolation) - } - - package func selectNode(_ id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws { - select(try requiredNode(for: id, isolation: isolation), isolation: isolation) - } - - package func selectNode( - _ id: DOMNode.ID?, - reveal: DOMRevealPolicy, - isolation: isolated (any Actor) = #isolation - ) throws { - guard let id else { - select(nil, reveal: reveal, isolation: isolation) - return - } - select(try requiredNode(for: id, isolation: isolation), reveal: reveal, isolation: isolation) - } - - package func requestChildren( - for id: DOMNode.ID, - depth: Int = 1, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await requiredNode(for: id, isolation: isolation).requestChildren(depth: depth, isolation: isolation) - } - - package func setDOMAttribute( - _ name: String, - value: String, - on id: DOMNode.ID, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let node = try requiredNode(for: id, isolation: isolation) - let target = try domTarget(owning: node.id.proxyID) - try await target.dom.setAttributeValue(node.id.proxyID, name: name, value: value) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - } - - package func setDOMOuterHTML( - _ html: String, - of id: DOMNode.ID, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let node = try requiredNode(for: id, isolation: isolation) - let target = try domTarget(owning: node.id.proxyID) - try await target.dom.setOuterHTML(node.id.proxyID, html: html) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - } - - package func removeDOMNodes( - _ nodeIDs: [DOMNode.ID], - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws -> DOMMutationResult { - requireOwner(isolation) - var seenNodeIDs: Set = [] - let uniqueNodes = try nodeIDs - .map { try requiredNode(for: $0, isolation: isolation) } - .filter { seenNodeIDs.insert($0.id).inserted } - let snapshot = try currentDOMTreeSnapshot(containing: uniqueNodes) - let sortedNodes = uniqueNodes.sorted { - snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count - } - let deletionTargets = try validatedDeletionTargets(for: sortedNodes) - var acceptedNodeIDs: [DOMNode.ID] = [] - for (node, target) in zip(sortedNodes, deletionTargets) { - do { - try await target.dom.removeNode(node.id.proxyID) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - acceptedNodeIDs.append(node.id) - } catch { - if acceptedNodeIDs.isEmpty == false { - clearSelectionIfDeleted(acceptedNodeIDs, snapshot: snapshot, isolation: isolation) - throw DOMDeletionPartialFailure( - deletedNodeCount: acceptedNodeIDs.count, - underlyingError: error - ) - } - throw error - } - } - clearSelectionIfDeleted(acceptedNodeIDs, snapshot: snapshot, isolation: isolation) - return DOMMutationResult(requestedNodeIDs: nodeIDs, acceptedNodeIDs: acceptedNodeIDs) - } - - /// Returns copied text for a DOM node in the requested format. - public func copyText( - _ kind: DOMNode.CopyTextKind, - for node: DOMNode, - isolation: isolated (any Actor) = #isolation - ) async throws -> String { - requireOwner(isolation) - try registeredNode(node) - switch kind { - case .html: - let page = try currentPageOrThrow() - return try await page.dom.outerHTML(of: node.id.proxyID) - case .selectorPath: - return try currentDOMTreeSnapshot(containing: [node]).selectorPath(for: node.id) - case .xPath: - return try currentDOMTreeSnapshot(containing: [node]).xPath(for: node.id) - } - } - - package func copyText( - _ kind: DOMNode.CopyTextKind, - for id: DOMNode.ID, - isolation: isolated (any Actor) = #isolation - ) async throws -> String { - try await copyText(kind, for: try requiredNode(for: id, isolation: isolation), isolation: isolation) - } - - /// Removes one DOM node from the inspected document. - public func delete(_ node: DOMNode, isolation: isolated (any Actor) = #isolation) async throws { - try await delete([node], isolation: isolation) - } - - /// Removes DOM nodes from the inspected document. - public func delete(_ nodes: [DOMNode], isolation: isolated (any Actor) = #isolation) async throws { - _ = try await deleteCountingRemovedNodes(nodes, isolation: isolation) - } - - @discardableResult - private func deleteCountingRemovedNodes( - _ nodes: [DOMNode], - isolation: isolated (any Actor) = #isolation - ) async throws -> Int { - requireOwner(isolation) - var seenNodeIDs: Set = [] - let uniqueNodes = try nodes - .map { try registeredNode($0) } - .filter { seenNodeIDs.insert($0.id).inserted } - let snapshot = try currentDOMTreeSnapshot(containing: uniqueNodes) - let sortedNodes = uniqueNodes - .sorted { - snapshot.ancestorNodeIDs(of: $0.id).count > snapshot.ancestorNodeIDs(of: $1.id).count - } - let deletionTargets = try validatedDeletionTargets(for: sortedNodes) - var removedNodes: [DOMNode] = [] - for (node, target) in zip(sortedNodes, deletionTargets) { - do { - try await target.dom.removeNode(node.id.proxyID) - recordDOMEditHistoryTarget(target, options: .init()) - try await target.dom.markUndoableState() - removedNodes.append(node) - } catch { - if removedNodes.isEmpty == false { - clearSelectionIfDeleted(removedNodes.map(\.id), snapshot: snapshot, isolation: isolation) - throw DOMDeletionPartialFailure( - deletedNodeCount: removedNodes.count, - underlyingError: error - ) - } - throw error - } - } - clearSelectionIfDeleted(removedNodes.map(\.id), snapshot: snapshot, isolation: isolation) - return removedNodes.count - } - - package func delete(nodeIDs: [DOMNode.ID], isolation: isolated (any Actor) = #isolation) async throws { - _ = try await deleteCountingRemovedNodes(nodeIDs: nodeIDs, isolation: isolation) - } - - @discardableResult - package func deleteCountingRemovedNodes( - nodeIDs: [DOMNode.ID], - isolation: isolated (any Actor) = #isolation - ) async throws -> Int { - var seenNodeIDs: Set = [] - let nodes = try nodeIDs - .filter { seenNodeIDs.insert($0).inserted } - .map { try requiredNode(for: $0, isolation: isolation) } - return try await deleteCountingRemovedNodes(nodes, isolation: isolation) - } - - #if DEBUG - package func installCurrentPageRetargetTaskForTesting(_ task: Task) { - currentPageRetargetTask = task - } - - package func startupTaskForTesting(isolation: isolated (any Actor) = #isolation) -> Task? { - requireOwner(isolation) - return startupTask - } - - package var eventPumpAppliedSequenceForTesting: UInt64 { - eventPumpAppliedSequenceForTestingStorage - } - - package func waitForEventPumpAppliedSequenceForTesting( - after baselineSequence: UInt64, - count: UInt64 = 1, - isolation: isolated (any Actor) = #isolation - ) async -> Bool { - requireOwner(isolation) - let minimumSequence = baselineSequence + count - if eventPumpAppliedSequenceForTestingStorage >= minimumSequence { - return true - } - - return await withCheckedContinuation { continuation in - let waiterID = nextEventPumpAppliedWaiterIDForTesting - nextEventPumpAppliedWaiterIDForTesting &+= 1 - eventPumpAppliedWaitersForTesting[waiterID] = EventPumpAppliedWaiterForTesting( - minimumSequence: minimumSequence, - continuation: continuation - ) - if eventPumpAppliedSequenceForTestingStorage >= minimumSequence { - resolveEventPumpAppliedWaiterForTesting(id: waiterID, result: true) - } - } - } - #endif - - /// Highlights a DOM node in the inspected page. - public func highlight(_ node: DOMNode, isolation: isolated (any Actor) = #isolation) async throws { - requireOwner(isolation) - try registeredNode(node) - let page = try currentPageOrThrow() - if node.id.proxyID.targetScopeRawValue == nil { - recordPageHighlight(documentGeneration: domDocumentGeneration, isolation: isolation) - } - try await page.dom.highlightNode(node.id.proxyID) - } - - package func highlightNode(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) async throws { - try await highlight(try requiredNode(for: id, isolation: isolation), isolation: isolation) - } - - /// Clears the current DOM highlight in the inspected page. - public func hideHighlight(isolation: isolated (any Actor) = #isolation) async throws { - requireOwner(isolation) - let page = try currentPageOrThrow() - try await page.dom.hideHighlight() - pageHighlightDocumentGeneration = nil - } - - package func domUndoRedoCommands(isolation: isolated (any Actor) = #isolation) throws -> DOMUndoRedoCommands { - requireOwner(isolation) - return DOMUndoRedoCommands( - context: self, - target: domEditHistoryTarget, - documentGeneration: domDocumentGeneration - ) - } - - package func undoDOMChange(isolation: isolated (any Actor) = #isolation) async throws { - try await domUndoRedoCommands(isolation: isolation).undo(isolation: isolation) - } - - package func redoDOMChange(isolation: isolated (any Actor) = #isolation) async throws { - try await domUndoRedoCommands(isolation: isolation).redo(isolation: isolation) - } - - /// Enables or disables WebKit's element picker. - public func setElementPickerEnabled( - _ isEnabled: Bool, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let page = try currentPageOrThrow() - WebInspectorDataKitLog.debug( - "DOM picker setInspectMode start enabled=\(isEnabled) target=\(page.id.rawValue)" - ) - do { - try await page.dom.setInspectMode(enabled: isEnabled) - } catch { - WebInspectorDataKitLog.debug( - "DOM picker setInspectMode failed enabled=\(isEnabled) target=\(page.id.rawValue): \(String(describing: error))" - ) - throw error - } - isElementPickerEnabled = isEnabled - notifyStatusChanged() - WebInspectorDataKitLog.debug( - "DOM picker setInspectMode finished enabled=\(isEnabled) target=\(page.id.rawValue)" - ) - } - - /// Reloads the inspected page. - public func reloadPage( - ignoringCache: Bool = false, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let page = try currentPageOrThrow() - try await page.page.reload(ignoringCache: ignoringCache) - } - - /// Returns a CSS selector path for a DOM node. - public func selectorPath(for node: DOMNode, isolation: isolated (any Actor) = #isolation) throws -> String { - requireOwner(isolation) - try registeredNode(node) - return try currentDOMTreeSnapshot(containing: [node]).selectorPath(for: node.id) - } - - package func selectorPath(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> String { - try selectorPath(for: try requiredNode(for: id, isolation: isolation), isolation: isolation) - } - - /// Returns an XPath expression for a DOM node. - public func xPath(for node: DOMNode, isolation: isolated (any Actor) = #isolation) throws -> String { - requireOwner(isolation) - try registeredNode(node) - return try currentDOMTreeSnapshot(containing: [node]).xPath(for: node.id) - } - - package func xPath(for id: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> String { - try xPath(for: try requiredNode(for: id, isolation: isolation), isolation: isolation) - } - - /// Creates a live DOM tree controller rooted at a node or the document root. - public func treeController( - root requestedRoot: DOMNode? = nil, - isolation: isolated (any Actor) = #isolation - ) async throws -> DOMTreeController { - requireOwner(isolation) - guard let root = requestedRoot ?? rootNode else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no DOM root node.") - } - guard nodesByID[root.id] === root else { - preconditionFailure("DOMTreeController root is not registered in this WebInspectorContext.") - } - - let tree = DOMTreeState(rootNode: root, selectedNode: selectedNode) - treeStates.append(WeakDOMTreeState(tree)) - pruneReleasedTreeStates() - return DOMTreeController(tree: tree) - } - - package func rootTreeController(isolation: isolated (any Actor) = #isolation) -> DOMTreeController { - requireOwner(isolation) - let tree = DOMTreeState(rootNode: rootNode, selectedNode: selectedNode) - treeStates.append(WeakDOMTreeState(tree)) - pruneReleasedTreeStates() - return DOMTreeController(tree: tree) - } - - /// Selects the Runtime execution context used by default evaluation calls. - public func selectContext(_ context: RuntimeContext?, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - guard let context else { - selectedContext = nil - return - } - guard runtimeContextsByID[context.id] === context else { - preconditionFailure("RuntimeContext is not registered in this WebInspectorContext.") - } - selectedContext = context - } - - /// Evaluates JavaScript in the selected or supplied Runtime context. - public func evaluate( - _ expression: String, - in context: RuntimeContext? = nil, - isolation: isolated (any Actor) = #isolation - ) async throws -> RuntimeEvaluation { - requireOwner(isolation) - if let context, runtimeContextsByID[context.id] !== context { - let error = WebInspectorProxyError.disconnected("RuntimeContext is not registered in this WebInspectorContext.") - throw error - } - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let executionContext = context ?? selectedContext - let result = try await currentPage.runtime.evaluate(expression, in: executionContext?.id.proxyID) - return RuntimeEvaluation( - object: registerRuntimeObject(result.object, owner: .client), - isException: result.wasThrown - ) - } - - /// Creates observable fetched results for a supported model type. - public func fetchedResults( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - requireOwner(isolation) - requireSupportedFetchDescriptor(descriptor) - let results = WebInspectorFetchedResults(fetchDescriptor: descriptor, sectionBy: sectionBy, modelContext: self) - switch descriptor.kind { - case .networkRequests: - guard let networkResults = results as? WebInspectorFetchedResults else { - preconditionFailure("NetworkRequest descriptors can only fetch NetworkRequest models.") - } - let plan = NetworkRequestQueryPlan(descriptor: networkResults.fetchDescriptor, context: self) - networkResults.setNetworkItems( - currentNetworkRequests(), - plan: plan, - lookup: { id in self.requestsByID[id] } - ) - networkFetchedResults.append(WeakWebInspectorFetchedResults(networkResults)) - case .consoleMessages: - guard let consoleResults = results as? WebInspectorFetchedResults else { - preconditionFailure("ConsoleMessage descriptors can only fetch ConsoleMessage models.") - } - consoleResults.setItems(consoleMessages(for: consoleResults.fetchDescriptor)) - consoleFetchedResults.append(WeakWebInspectorFetchedResults(consoleResults)) - } - return results - } - - /// Creates observable fetched results from a mutable fetch request. - public func fetchedResults( - for request: WebInspectorFetchRequest, - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - fetchedResults(for: request.fetchDescriptor, sectionBy: sectionBy, isolation: isolation) - } - - /// Creates observable fetched results sectioned by a string key path. - public func fetchedResults( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - fetchedResults( - for: descriptor, - sectionBy: WebInspectorSectionDescriptor(keyPath), - isolation: isolation - ) - } - - /// Creates observable fetched results sectioned by an optional string key path. - public func fetchedResults( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - fetchedResults( - for: descriptor, - sectionBy: WebInspectorSectionDescriptor(keyPath), - isolation: isolation - ) - } - - /// Creates observable fetched results sectioned by a raw-representable string key path. - public func fetchedResults< - Model: WebInspectorFetchableModel, - Value: RawRepresentable & Hashable & Sendable - >( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults where Value.RawValue == String { - fetchedResults( - for: descriptor, - sectionBy: WebInspectorSectionDescriptor(keyPath), - isolation: isolation - ) - } - - /// Creates observable fetched results sectioned by an optional raw-representable string key path. - public func fetchedResults< - Model: WebInspectorFetchableModel, - Value: RawRepresentable & Hashable & Sendable - >( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults where Value.RawValue == String { - fetchedResults( - for: descriptor, - sectionBy: WebInspectorSectionDescriptor(keyPath), - isolation: isolation - ) - } - - /// Creates a fetched-results controller for a supported model type. - public func fetchedResultsController( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - requireOwner(isolation) - return WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: sectionBy, isolation: isolation) - ) - } - - /// Creates a fetched-results controller from a mutable fetch request. - public func fetchedResultsController( - for request: WebInspectorFetchRequest, - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: request, sectionBy: sectionBy, isolation: isolation) - ) - } - - /// Creates a fetched-results controller sectioned by a string key path. - public func fetchedResultsController( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: keyPath, isolation: isolation) - ) - } - - /// Creates a fetched-results controller sectioned by an optional string key path. - public func fetchedResultsController( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: keyPath, isolation: isolation) - ) - } - - /// Creates a fetched-results controller sectioned by a raw-representable string key path. - public func fetchedResultsController< - Model: WebInspectorFetchableModel, - Value: RawRepresentable & Hashable & Sendable - >( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController where Value.RawValue == String { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: keyPath, isolation: isolation) - ) - } - - /// Creates a fetched-results controller sectioned by an optional raw-representable string key path. - public func fetchedResultsController< - Model: WebInspectorFetchableModel, - Value: RawRepresentable & Hashable & Sendable - >( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy keyPath: KeyPath, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController where Value.RawValue == String { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: keyPath, isolation: isolation) - ) - } - - func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - for results: WebInspectorFetchedResults, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - requireSupportedFetchDescriptor(descriptor) - guard results.modelContext === self else { - preconditionFailure("WebInspectorFetchedResults is not registered in this WebInspectorContext.") - } - switch descriptor.kind { - case .networkRequests: - guard let networkDescriptor = descriptor as? WebInspectorFetchDescriptor, - let networkResults = results as? WebInspectorFetchedResults else { - preconditionFailure("NetworkRequest descriptors can only update NetworkRequest fetched results.") - } - let plan = NetworkRequestQueryPlan(descriptor: networkDescriptor, context: self) - networkResults.applyNetworkFetchDescriptor( - networkDescriptor, - plan: plan, - requests: currentNetworkRequests(), - lookup: { id in self.requestsByID[id] } - ) - case .consoleMessages: - guard let consoleDescriptor = descriptor as? WebInspectorFetchDescriptor, - let consoleResults = results as? WebInspectorFetchedResults else { - preconditionFailure("ConsoleMessage descriptors can only update ConsoleMessage fetched results.") - } - consoleResults.applyFetchDescriptor(consoleDescriptor, items: consoleMessages(for: consoleDescriptor)) - } - } - - private func requireSupportedFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor - ) { - if descriptor.kind == .networkRequests || descriptor.kind == .consoleMessages { - return - } - guard descriptor.requiresRecordBackedQuery == false else { - preconditionFailure( - "Predicate, sort, limit, and offset fetch descriptors require a record-backed DataKit query index." - ) - } - } - - func fetchResponseBody( - for request: NetworkRequest, - expectedBody: NetworkBody, - isolation: isolated (any Actor) = #isolation - ) async { - requireOwner(isolation) - guard let currentPage else { - finishResponseBodyFetch( - .failure(.disconnected("WebInspectorDataKit has no current page target.")), - for: request, - expectedBody: expectedBody - ) - return - } - - do { - let body = try await currentPage.network.responseBody( - for: request.proxyID, - backendResourceIdentifier: request.backendResourceIdentifier - ) - finishResponseBodyFetch(.success(body), for: request, expectedBody: expectedBody) - } catch let error as WebInspectorProxyError { - finishResponseBodyFetch(.failure(error), for: request, expectedBody: expectedBody) - } catch { - finishResponseBodyFetch( - .failure(.commandFailed( - domain: "Network", - method: "getResponseBody", - message: String(describing: error) - )), - for: request, - expectedBody: expectedBody - ) - } - } - - private func finishResponseBodyFetch( - _ result: Result, - for request: NetworkRequest, - expectedBody: NetworkBody - ) { - guard requestsByID[request.id] === request else { - return - } - request.finishResponseBodyFetch(result: result, expectedBody: expectedBody) - } - - func requestChildren( - for node: DOMNode, - depth: Int, - isolation: isolated (any Actor) = #isolation - ) async { - requireOwner(isolation) - guard nodesByID[node.id] === node else { - skipEvent("requestChildren ignored a DOMNode from a previous document generation") - return - } - guard let currentPage else { - skipEvent("requestChildren ignored: no current page target") - return - } - - do { - try await currentPage.dom.requestChildNodes(node.id.proxyID, depth: depth) - } catch is CancellationError { - return - } catch { - failIfTerminal(error, operation: "DOM.requestChildNodes") - } - } - - func properties( - for object: RuntimeObject, - isolation: isolated (any Actor) = #isolation - ) async throws -> [RuntimeObject.Property] { - requireOwner(isolation) - try registeredRuntimeObject(object) - guard let proxyID = object.proxyID else { - return [] - } - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let descriptors = try await currentPage.runtime.properties(of: proxyID) - return descriptors.map { descriptor in - let remoteValue = descriptor.value - let childObject = remoteValue.flatMap { value in - value.id == nil ? nil : registerRuntimeObject(value, owner: .client) - } - return RuntimeObject.Property( - name: descriptor.name, - value: remoteValue.flatMap { runtimeValueText(for: $0) }, - object: childObject - ) - } - } - - func collectionEntries( - for object: RuntimeObject, - isolation: isolated (any Actor) = #isolation - ) async throws -> [RuntimeObject.Entry] { - requireOwner(isolation) - try registeredRuntimeObject(object) - guard let proxyID = object.proxyID else { - return [] - } - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - - let entries = try await currentPage.runtime.collectionEntries(of: proxyID) - return entries.map { entry in - RuntimeObject.Entry( - key: entry.key.map { registerRuntimeObject($0, owner: .client) }, - value: registerRuntimeObject(entry.value, owner: .client) - ) - } - } - - @discardableResult - private func registeredRuntimeObject(_ object: RuntimeObject) throws -> RuntimeObject { - guard runtimeObjectsByID[object.id] === object else { - let error = WebInspectorProxyError.disconnected("RuntimeObject is not registered in this WebInspectorContext.") - throw error - } - return object - } - - private func registerRuntimeObject( - _ payload: Runtime.RemoteObject, - owner: RuntimeObjectOwner - ) -> RuntimeObject { - if let proxyID = payload.id, - let id = runtimeObjectIDsByProxyID[proxyID], - let object = runtimeObjectsByID[id] { - object.update(from: payload) - runtimeObjectOwnersByID[id, default: []].insert(owner) - return object - } - - let id: RuntimeObject.ID - if let proxyID = payload.id { - id = RuntimeObject.ID(remote: proxyID) - runtimeObjectIDsByProxyID[proxyID] = id - } else { - id = RuntimeObject.ID(synthetic: nextRuntimeObjectOrdinal) - nextRuntimeObjectOrdinal += 1 - } - - let object = RuntimeObject(id: id, remoteObject: payload, modelContext: self) - runtimeObjectsByID[id] = object - runtimeObjectOwnersByID[id] = [owner] - return object - } - - private func clearRuntimeObjects() { - runtimeObjectsByID = [:] - runtimeObjectIDsByProxyID = [:] - runtimeObjectOwnersByID = [:] - nextRuntimeObjectOrdinal = 0 - } - - private func clearRuntimeObjects(targetID: WebInspectorTarget.ID) { - for (id, object) in runtimeObjectsByID.map({ ($0.key, $0.value) }) { - guard object.proxyID?.targetScopeRawValue == targetID.rawValue else { - continue - } - runtimeObjectsByID[id] = nil - runtimeObjectOwnersByID[id] = nil - if let proxyID = object.proxyID, - runtimeObjectIDsByProxyID[proxyID] == id { - runtimeObjectIDsByProxyID.removeValue(forKey: proxyID) - } - } - } - - private func unregisterRuntimeObjects(owner: RuntimeObjectOwner) { - for (id, owners) in runtimeObjectOwnersByID.map({ ($0.key, $0.value) }) { - var remainingOwners = owners - remainingOwners.remove(owner) - guard remainingOwners.isEmpty else { - runtimeObjectOwnersByID[id] = remainingOwners - continue - } - runtimeObjectOwnersByID.removeValue(forKey: id) - if let object = runtimeObjectsByID.removeValue(forKey: id), - let proxyID = object.proxyID, - runtimeObjectIDsByProxyID[proxyID] == id { - runtimeObjectIDsByProxyID.removeValue(forKey: proxyID) - } - } - } - - private func unregisterRuntimeObject(_ object: RuntimeObject, owner: RuntimeObjectOwner) { - guard var owners = runtimeObjectOwnersByID[object.id] else { - return - } - owners.remove(owner) - guard owners.isEmpty else { - runtimeObjectOwnersByID[object.id] = owners - return - } - runtimeObjectOwnersByID.removeValue(forKey: object.id) - runtimeObjectsByID[object.id] = nil - if let proxyID = object.proxyID, - runtimeObjectIDsByProxyID[proxyID] == object.id { - runtimeObjectIDsByProxyID.removeValue(forKey: proxyID) - } - } - - private func runtimeValueText(for object: Runtime.RemoteObject) -> String? { - if let description = object.description { - return description - } - guard let value = object.value else { - return nil - } - switch value { - case let .string(value): - return value - case let .number(value): - return String(value) - case let .bool(value): - return String(value) - case .null: - return "null" - case .array, - .object: - return nil - } - } - - /// Stops observing the inspected page and tears down context-owned state. - public func stop(isolation: isolated (any Actor) = #isolation) async { - requireOwner(isolation) - await detach(isolation: isolation) - } - - func detach(isolation: isolated (any Actor) = #isolation) async { - requireOwner(isolation) - startupTask?.cancel() - startupTask = nil - currentPageRetargetTask?.cancel() - currentPageRetargetTask = nil - currentPageCleanupTask?.cancel() - currentPageCleanupTask = nil - documentReloadTask?.cancel() - documentReloadTask = nil - inspectResolutionTask?.cancel() - inspectResolutionTask = nil - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - cancelFrameDocumentLoadTasks() - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - for task in styleToggleTasks.values { - task.cancel() - } - styleToggleTasks = [:] - stopEventPumps() - cancelConsoleObjectGroupReleaseTasks() - pendingInspectedNodeID = nil - isElementPickerEnabled = false - currentPage = nil - advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) - resetAttachmentBackedModels(isolation: isolation) - teardownError = nil - teardownError = await disableEnabledDomains(isolation: isolation) - transition(to: .detached) - } - - private func startup(isolation: isolated (any Actor)) async { - requireOwner(isolation) - let generation = currentPageGeneration - if let teardownError = await disableEnabledDomainsBeforeRestart(isolation: isolation) { - failIfTerminal(teardownError, operation: "domain disable before restart") - if case .failed = state { - return - } - } - - do { - let target = try await proxy.waitForCurrentPage() - currentPage = target - subscribe(to: target, isolation: isolation) - await waitForCurrentPageEventSubscriptions(target, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - resetReplayBackedModelsBeforeEnable() - try await enableInspectorTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableRuntimeTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableNetworkTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - var document = try await loadCurrentDOMDocument(on: target, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableConsoleTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - document = try await reloadDOMDocumentIfNeeded(document, on: target, isolation: isolation) - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - applyDocument(document.node, isolation: isolation) - transition(to: .attached) - } catch is CancellationError { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } catch let error as WebInspectorProxyError { - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - await logStartupTeardownFailure(isolation: isolation) - fail(error) - } catch { - guard Task.isCancelled == false else { - await disableEnabledDomainsAfterCancellation(isolation: isolation) - return - } - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - await logStartupTeardownFailure(isolation: isolation) - fail(.attachFailed(String(describing: error))) - } - } - - private func waitForCurrentPageEventSubscriptions( - _ target: WebInspectorTarget, - isolation: isolated (any Actor) - ) async { - _ = isolation - await target.waitForModelEventSubscriptions() - await proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: .target) - await proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: .page) - } - - private func disableEnabledDomainsBeforeRestart( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - let error = await disableEnabledDomains(isolation: isolation) - await discardCurrentPageDomainLeases(isolation: isolation) - return error - } - - private func discardCurrentPageDomainLeases(isolation: isolated (any Actor)) async { - _ = isolation - if let target = inspectorTrackingTarget { - inspectorTrackingTarget = nil - await domainEnablement.discardLease(.inspector, on: target) - } - if let target = runtimeTrackingTarget { - runtimeTrackingTarget = nil - await domainEnablement.discardLease(.runtime, on: target) - } - if let target = networkTrackingTarget { - networkTrackingTarget = nil - await domainEnablement.discardLease(.network, on: target) - } - if let target = consoleTrackingTarget { - consoleTrackingTarget = nil - await domainEnablement.discardLease(.console, on: target) - } - } - - private func enableInspectorTracking( - on target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) - ) async throws { - _ = isolation - try await domainEnablement.acquire(.inspector, on: target) - guard isCurrentPageGeneration(generation, isolation: isolation) else { - await releaseLateAcquiredDomain(.inspector, on: target, isolation: isolation) - return - } - inspectorTrackingTarget = target - } - - private func enableRuntimeTracking( - on target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) - ) async throws { - _ = isolation - try await domainEnablement.acquire(.runtime, on: target) - guard isCurrentPageGeneration(generation, isolation: isolation) else { - await releaseLateAcquiredDomain(.runtime, on: target, isolation: isolation) - return - } - runtimeTrackingTarget = target - } - - private func enableConsoleTracking( - on target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) - ) async throws { - _ = isolation - try await domainEnablement.acquire(.console, on: target) - guard isCurrentPageGeneration(generation, isolation: isolation) else { - await releaseLateAcquiredDomain(.console, on: target, isolation: isolation) - return - } - consoleTrackingTarget = target - } - - private func enableNetworkTracking( - on target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) - ) async throws { - _ = isolation - try await domainEnablement.acquire(.network, on: target) - guard isCurrentPageGeneration(generation, isolation: isolation) else { - await releaseLateAcquiredDomain(.network, on: target, isolation: isolation) - return - } - networkTrackingTarget = target - } - - private func releaseLateAcquiredDomain( - _ domain: WebInspectorEnabledDomain, - on target: WebInspectorTarget, - isolation: isolated (any Actor) - ) async { - _ = isolation - if let error = await domainEnablement.release(domain, on: target) { - WebInspectorDataKitLog.debug( - "domain late-acquire release failed domain=\(domain.rawValue) target=\(target.id.rawValue) error=\(String(describing: error))" - ) - } - } - - private func loadCurrentDOMDocument( - on target: WebInspectorTarget, - isolation: isolated (any Actor) - ) async throws -> LoadedDOMDocument { - _ = isolation - while true { - let generation = domDocumentGeneration - let document = try await target.dom.getDocument() - guard Task.isCancelled == false else { - throw CancellationError() - } - guard isDOMDocumentGeneration(generation, isolation: isolation) else { - continue - } - return (node: document, generation: generation) - } - } - - private func reloadDOMDocumentIfNeeded( - _ document: LoadedDOMDocument, - on target: WebInspectorTarget, - isolation: isolated (any Actor) - ) async throws -> LoadedDOMDocument { - if isDOMDocumentGeneration(document.generation, isolation: isolation) { - return document - } - return try await loadCurrentDOMDocument(on: target, isolation: isolation) - } - - private func resetReplayBackedModelsBeforeEnable() { - clearExecutionContexts() - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] - refreshAllConsoleMessages() - } - - private func resetNetworkModelsForNewAttachment() { - clearedNetworkRequestIDs = [] - requestsByID = [:] - orderedRequestIDs = [] - networkRequestOrderIndicesByID = [:] - networkRequestIndexNeedsRebuild = true - clearNetworkRequestIndex() - networkCollectionState.replaceCount(0) - resetNetworkFetchedResults() - } - - private func resetCurrentPageLifecycleModels(isolation: isolated (any Actor)) { - resetDOM(isolation: isolation) - clearExecutionContexts() - clearConsoleMessages() - } - - private func resetAttachmentBackedModels(isolation: isolated (any Actor)) { - resetCurrentPageLifecycleModels(isolation: isolation) - resetNetworkModelsForNewAttachment() - } - - private func disableEnabledDomains( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - let consoleError = await disableConsoleTracking(isolation: isolation) - let runtimeError = await disableRuntimeTracking(isolation: isolation) - let networkError = await disableNetworkTracking(isolation: isolation) - let inspectorError = await disableInspectorTracking(isolation: isolation) - return consoleError ?? runtimeError ?? networkError ?? inspectorError - } - - private func disableInspectorTracking( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - _ = isolation - guard let target = inspectorTrackingTarget else { - return nil - } - inspectorTrackingTarget = nil - return await domainEnablement.release(.inspector, on: target) - } - - private func disableConsoleTracking( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - _ = isolation - guard let target = consoleTrackingTarget else { - return nil - } - consoleTrackingTarget = nil - return await domainEnablement.release(.console, on: target) - } - - private func disableRuntimeTracking( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - _ = isolation - guard let target = runtimeTrackingTarget else { - return nil - } - runtimeTrackingTarget = nil - return await domainEnablement.release(.runtime, on: target) - } - - private func disableNetworkTracking( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - _ = isolation - guard let target = networkTrackingTarget else { - return nil - } - networkTrackingTarget = nil - return await domainEnablement.release(.network, on: target) - } - - private func disableEnabledDomainsAfterCancellation( - isolation: isolated (any Actor) - ) async { - if let error = await disableEnabledDomains(isolation: isolation) { - failIfTerminal(error, operation: "domain disable after cancellation") - } - } - - private func disableEnabledDomainsAfterStartupFailure( - isolation: isolated (any Actor) - ) async -> WebInspectorProxyError? { - await disableEnabledDomains(isolation: isolation) - } - - /// Best-effort teardown after a startup failure: the startup error is the - /// root cause and owns `state`; a teardown error must not mask it. - private func logStartupTeardownFailure(isolation: isolated (any Actor)) async { - if let teardownError = await disableEnabledDomainsAfterStartupFailure(isolation: isolation) { - WebInspectorDataKitLog.debug("domain disable after startup failure also failed: \(String(describing: teardownError))") - } - } - - private func subscribe(to target: WebInspectorTarget, isolation: isolated (any Actor)) { - stopEventPumps() - - let domPump = WebInspectorEventPump(stream: target.dom.events, isolation: isolation) { [weak self] event in - guard let self else { return } - self.apply(event, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - let networkPump = WebInspectorEventPump(stream: target.network.events, isolation: isolation) { [weak self] event in - guard let self else { return } - await self.apply(event, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - let cssPump = WebInspectorEventPump(stream: target.css.events, isolation: isolation) { [weak self] event in - guard let self else { return } - self.apply(event, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - let consolePump = WebInspectorEventPump(stream: target.targetedConsoleEvents, isolation: isolation) { [weak self] event in - guard let self else { return } - self.apply(event.event, targetID: event.targetID, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - let runtimePump = WebInspectorEventPump(stream: target.runtime.events, isolation: isolation) { [weak self, targetID = target.id] event in - guard let self else { return } - self.apply(event, targetID: targetID, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - let lifecyclePump = WebInspectorEventPump(stream: target.lifecycleEvents, isolation: isolation) { [weak self] event in - guard let self else { return } - self.apply(event, isolation: isolation) - self.recordEventPumpAppliedForTesting() - } - - eventPumps = [domPump, networkPump, cssPump, consolePump, runtimePump, lifecyclePump] - } - - private func stopEventPumps() { - for pump in eventPumps { - pump.stop() - } - eventPumps = [] - } - - private func recordEventPumpAppliedForTesting() { - #if DEBUG - eventPumpAppliedSequenceForTestingStorage &+= 1 - let completedWaiterIDs = eventPumpAppliedWaitersForTesting.compactMap { id, waiter in - eventPumpAppliedSequenceForTestingStorage >= waiter.minimumSequence ? id : nil - } - for waiterID in completedWaiterIDs { - resolveEventPumpAppliedWaiterForTesting(id: waiterID, result: true) - } - #endif - } - - private func resolveEventPumpAppliedWaitersForTesting(result: Bool) { - #if DEBUG - let waiterIDs = Array(eventPumpAppliedWaitersForTesting.keys) - for waiterID in waiterIDs { - resolveEventPumpAppliedWaiterForTesting(id: waiterID, result: result) - } - #endif - } - - private func resolveEventPumpAppliedWaiterForTesting(id: UInt64, result: Bool) { - #if DEBUG - guard let waiter = eventPumpAppliedWaitersForTesting.removeValue(forKey: id) else { - return - } - waiter.continuation.resume(returning: result) - #endif - } - - private func notifyDOMTreeSnapshot( - reason: DOMTreeSnapshotReason, - isolation: isolated (any Actor) - ) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applySnapshot(rootNode: rootNode, selectedNode: selectedNode, reason: reason) - } - } - - private func notifyDOMTreeChildrenReplaced(parent: DOMNode, isolation: isolated (any Actor)) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applyChildrenReplaced(parent: parent) - } - } - - private func notifyDOMTreeChildInserted( - parent: DOMNode, - node: DOMNode, - previousSiblingID: DOMNode.ID?, - isolation: isolated (any Actor) - ) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applyChildInserted(parent: parent, node: node, previousSiblingID: previousSiblingID) - } - } - - private func notifyDOMTreeChildRemoved( - parent: DOMNode, - nodeID: DOMNode.ID, - isolation: isolated (any Actor) - ) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applyChildRemoved(parent: parent, nodeID: nodeID) - } - } - - private func notifyDOMTreeChildCountChanged(node: DOMNode, isolation: isolated (any Actor)) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applyChildCountChanged(node: node) - } - } - - private func notifyDOMTreeNodeChanged(_ node: DOMNode, isolation: isolated (any Actor)) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applyNodeChanged(node) - } - } - - private func notifyDOMTreeSelectionChanged( - _ node: DOMNode?, - reveal: DOMRevealPolicy = .selectAndScroll, - isolation: isolated (any Actor) - ) { - _ = isolation - pruneReleasedTreeStates() - for reference in treeStates { - reference.tree?.applySelectionChanged(nodeID: node?.id, reveal: reveal) - } - } - - private func clearSelectionIfDeleted( - _ deletedRootIDs: [DOMNode.ID], - snapshot: DOMTreeSnapshot, - isolation: isolated (any Actor) - ) { - guard let selectedNode else { - return - } - let deletedRootIDs = Set(deletedRootIDs) - guard deletedRootIDs.contains(selectedNode.id) - || snapshot.ancestorNodeIDs(of: selectedNode.id).contains(where: deletedRootIDs.contains) - else { - return - } - - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - selectedNode.setElementStyles(nil) - self.selectedNode = nil - notifyDOMTreeSelectionChanged(nil, isolation: isolation) - notifyStatusChanged() - } - - @discardableResult - private func registeredNode(_ node: DOMNode) throws -> DOMNode { - guard nodesByID[node.id] === node else { - throw WebInspectorProxyError.disconnected("DOMNode is not registered in this WebInspectorContext.") - } - return node - } - - private func currentPageOrThrow() throws -> WebInspectorTarget { - guard let currentPage else { - throw WebInspectorProxyError.disconnected("WebInspectorDataKit has no current page target.") - } - return currentPage - } - - private func domTarget(owning id: DOM.Node.ID) throws -> WebInspectorTarget { - if let scopedTargetRawValue = id.targetScopeRawValue { - return proxy.frameTarget(id: WebInspectorTarget.ID(scopedTargetRawValue)) - } - return try currentPageOrThrow() - } - - private func cssTarget(owning id: CSS.Style.ID) throws -> WebInspectorTarget { - if let scopedTargetRawValue = id.targetScopeRawValue { - return proxy.frameTarget(id: WebInspectorTarget.ID(scopedTargetRawValue)) - } - return try currentPageOrThrow() - } - - private func cssTarget(owning id: CSS.Rule.ID) throws -> WebInspectorTarget { - if let scopedTargetRawValue = id.targetScopeRawValue { - return proxy.frameTarget(id: WebInspectorTarget.ID(scopedTargetRawValue)) - } - return try currentPageOrThrow() - } - - private func cssTarget(owning id: CSS.StyleSheet.ID) throws -> WebInspectorTarget { - if let scopedTargetRawValue = id.targetScopeRawValue { - return proxy.frameTarget(id: WebInspectorTarget.ID(scopedTargetRawValue)) - } - return try currentPageOrThrow() - } - - private static func markDOMUndoableStateIfNeeded( - on target: WebInspectorTarget, - options: WebInspectorMutationOptions - ) async throws { - switch options.undo { - case .automatic: - try await target.dom.markUndoableState() - case .disabled: - break - } - } - - private func recordDOMEditHistoryTarget(_ target: WebInspectorTarget, options: WebInspectorMutationOptions) { - guard options.undo == .automatic else { - return - } - domEditHistoryTarget = target - didInvalidateDOMEditHistoryTarget = false - } - - private func validatedDeletionTargets(for nodes: [DOMNode]) throws -> [WebInspectorTarget] { - var deletionTargets: [WebInspectorTarget] = [] - var firstTargetID: WebInspectorTarget.ID? - for node in nodes { - let target = try domTarget(owning: node.id.proxyID) - if let firstTargetID, firstTargetID != target.id { - throw WebInspectorProxyError.commandFailed( - domain: "DOM", - method: "removeNode", - message: "Deleting nodes from multiple DOM targets in one mutation is not supported." - ) - } - firstTargetID = target.id - deletionTargets.append(target) - } - return deletionTargets - } - - private func currentDOMTreeSnapshot() -> DOMTreeSnapshot { - DOMTreeSnapshot.make(revision: 0, rootNode: rootNode, selectedNode: selectedNode) - } - - private func currentDOMTreeSnapshot(containing nodes: [DOMNode]) throws -> DOMTreeSnapshot { - let snapshot = currentDOMTreeSnapshot() - for node in nodes where snapshot.node(for: node.id) == nil { - throw WebInspectorProxyError.disconnected("DOMNode is not in the current DOM tree.") - } - return snapshot - } - - private func isNodeAttachedToCurrentDOMTree(_ node: DOMNode) -> Bool { - guard let rootNode else { - return false - } - var visitedNodeIDs = Set() - return subtree(rootNode, contains: node.id, visitedNodeIDs: &visitedNodeIDs) - } - - private func subtree( - _ root: DOMNode, - contains nodeID: DOMNode.ID, - visitedNodeIDs: inout Set - ) -> Bool { - guard visitedNodeIDs.insert(root.id).inserted else { - return false - } - if root.id == nodeID { - return true - } - for associatedRoot in root.associatedSubtreeRoots() { - if subtree(associatedRoot, contains: nodeID, visitedNodeIDs: &visitedNodeIDs) { - return true - } - } - guard case let .loaded(children) = root.children else { - return false - } - for child in children { - if subtree(child, contains: nodeID, visitedNodeIDs: &visitedNodeIDs) { - return true - } - } - return false - } - - private func isCurrentPageGeneration( - _ generation: Int, - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation - return currentPageGeneration == generation - } - - private func isDOMDocumentGeneration( - _ generation: Int, - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation - return domDocumentGeneration == generation - } - - @discardableResult - private func advanceCurrentPageGeneration(isolation: isolated (any Actor)) -> Int { - _ = isolation - currentPageGeneration += 1 - return currentPageGeneration - } - - @discardableResult - private func advanceDOMDocumentGeneration(isolation: isolated (any Actor)) -> Int { - _ = isolation - domDocumentGeneration += 1 - return domDocumentGeneration - } - - private func pruneReleasedTreeStates() { - treeStates.removeAll { $0.tree == nil } - } - - private func fail(_ error: WebInspectorProxyError) { - switch state { - case .failed, .detached: - return - case .attaching, .attached: - transition(to: .failed(error)) - } - } - - /// Inbound events may reference entities this context has not materialized: - /// WebKit only reports what it has bound for this frontend, but binding can - /// predate domain tracking (attach mid-flight) or outlive this context's - /// index (evicted subtrees). Skipping is the protocol-correct response; - /// `state = .failed` is reserved for terminal connection loss. - private func skipEvent(_ reason: String) { - WebInspectorDataKitLog.debug("event skipped: \(reason)") - } - - private func logDescription(_ id: DOMNode.ID) -> String { - logDescription(id.proxyID) - } - - private func logDescription(_ id: DOM.Node.ID) -> String { - "\(id.unscopedRawValue)@\(id.targetScopeRawValue ?? "current-page")" - } - - /// Command failures surface at their call site (thrown, or a per-model - /// phase such as `NetworkBody.Phase.failed`); only terminal connection - /// loss moves the whole context to `.failed`. - private func failIfTerminal(_ error: Error, operation: String) { - switch error { - case let proxyError as WebInspectorProxyError: - switch proxyError { - case .disconnected, .unsupported, .attachFailed: - fail(proxyError) - case .closed: - WebInspectorDataKitLog.debug("\(operation) raced connection close") - case .commandFailed, .timeout: - WebInspectorDataKitLog.debug("\(operation) failed: \(String(describing: proxyError))") - } - default: - WebInspectorDataKitLog.debug("\(operation) failed: \(String(describing: error))") - } - } - - private func requireOwner(_ isolation: isolated (any Actor)) { - precondition(isolation === owner, "WebInspectorContext must be used from the actor that created it.") - } - - private func transition(to newState: State) { - state = newState - notifyStatusChanged() - WebInspectorDataKitLog.debug("context state=\(newState.logDescription)") - } - - private func notifyStatusChanged() { - guard statusRelay.hasContinuations else { - return - } - statusRelay.yield(status) - } - - func reloadDocument(isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - guard let currentPage else { - skipEvent("reloadDocument ignored: no current page target") - return - } - - let generation = domDocumentGeneration - documentReloadTask?.cancel() - documentReloadTask = Task { [weak self, currentPage, generation] in - _ = isolation - do { - let document = try await currentPage.dom.getDocument() - guard Task.isCancelled == false else { - return - } - guard self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { - return - } - self?.applyDocument(document, reason: .documentUpdated, isolation: isolation) - } catch is CancellationError { - return - } catch { - self?.failIfTerminal(error, operation: "DOM.getDocument") - } - } - } - -} - -extension WebInspectorContext { - func apply(_ event: WebInspectorTargetLifecycleEvent, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - switch event { - case let .didCommitProvisionalTarget(commit): - applyCurrentPageTargetCommit(commit, isolation: isolation) - case let .frameNavigated(frame): - applyCurrentPageFrameNavigated(frame, isolation: isolation) - case let .targetDestroyed(targetID): - applyCurrentPageTargetDestroyed(targetID, isolation: isolation) - case let .frameDetached(frameID): - applyCurrentPageFrameDetached(frameID, isolation: isolation) - case .unknown: - break - } - } - - private func applyCurrentPageTargetCommit( - _ commit: WebInspectorTargetCommitLifecycle, - isolation: isolated (any Actor) - ) { - guard commit.newTarget.id == .currentPage else { - skipEvent("Target.didCommitProvisionalTarget ignored for non-current-page target") - return - } - guard case .page = commit.newTarget.kind, - commit.newTarget.isProvisional == false else { - skipEvent("Target.didCommitProvisionalTarget ignored for non-top-level current page target") - return - } - guard let target = currentPage else { - fail(.disconnected("Current page target committed while WebInspectorDataKit had no current page.")) - return - } - let refreshedTarget = target.withPageBinding(from: commit.newTarget) - currentPage = refreshedTarget - - currentPageRetargetTask?.cancel() - documentReloadTask?.cancel() - documentReloadTask = nil - let generation = advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) - resetCurrentPageLifecycleModels(isolation: isolation) - cancelConsoleObjectGroupReleaseTasks() - for task in styleToggleTasks.values { - task.cancel() - } - styleToggleTasks = [:] - - currentPageRetargetTask = Task { [weak self, refreshedTarget, generation] in - _ = isolation - await self?.retargetCurrentPage(refreshedTarget, generation: generation, isolation: isolation) - } - } - - private func retargetCurrentPage( - _ target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) - ) async { - defer { - if isCurrentPageGeneration(generation, isolation: isolation) { - currentPageRetargetTask = nil - } - } - await discardCurrentPageDomainLeases(isolation: isolation) - - do { - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableInspectorTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableRuntimeTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableNetworkTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - var document = try await loadCurrentDOMDocument(on: target, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - try await enableConsoleTracking(on: target, generation: generation, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - document = try await reloadDOMDocumentIfNeeded(document, on: target, isolation: isolation) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - applyDocument(document.node, reason: .pageChanged, isolation: isolation) - if case .attaching = state { - transition(to: .attached) - } - } catch is CancellationError { - return - } catch { - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - failIfTerminal(error, operation: "current page retarget") - } - } - - private func applyCurrentPageFrameNavigated( - _ frame: WebInspectorPageFrameLifecycle, - isolation: isolated (any Actor) - ) { - guard let currentPage else { - skipEvent("Page.frameNavigated ignored: no current page target") - return - } - guard frame.parentID == nil || frame.id == currentPage.frameID else { - detachProjectedFrameDocument(forFrameID: frame.id, isolation: isolation) - return - } - advanceDOMDocumentGeneration(isolation: isolation) - resetDOM(isolation: isolation) - clearExecutionContexts() - guard currentPageRetargetTask == nil, - state != .attaching else { - return - } - reloadDocument(isolation: isolation) - } - - private func applyCurrentPageFrameDetached( - _ frameID: FrameID, - isolation: isolated (any Actor) - ) { - detachProjectedFrameDocument(forFrameID: frameID, isolation: isolation) - } - - private func applyCurrentPageTargetDestroyed( - _ targetID: WebInspectorTarget.ID, - isolation: isolated (any Actor) - ) { - guard targetID == .currentPage else { - return - } - guard currentPage != nil else { - skipEvent("Target.targetDestroyed ignored: no current page target") - return - } - - // A current-page Target.targetDestroyed is a physical route loss during - // retarget, not a clean SDK close signal. Real close is owned by the - // proxy connection close path. - startupTask?.cancel() - startupTask = nil - currentPageRetargetTask?.cancel() - currentPageCleanupTask?.cancel() - currentPageCleanupTask = nil - documentReloadTask?.cancel() - documentReloadTask = nil - for task in styleToggleTasks.values { - task.cancel() - } - styleToggleTasks = [:] - cancelConsoleObjectGroupReleaseTasks() - let generation = advanceCurrentPageGeneration(isolation: isolation) - advanceDOMDocumentGeneration(isolation: isolation) - - currentPageRetargetTask = Task { [weak self, generation] in - _ = isolation - await self?.retargetDestroyedCurrentPage(generation: generation, isolation: isolation) - } - } - - private func retargetDestroyedCurrentPage( - generation: Int, - isolation: isolated (any Actor) - ) async { - defer { - if isCurrentPageGeneration(generation, isolation: isolation) { - currentPageRetargetTask = nil - } - } - do { - let gracePeriod = await proxy.bootstrapGracePeriod - var replacement = try await proxy.waitForCurrentPageReplacement(gracePeriod: gracePeriod) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - if replacement == nil { - // The destroyed page has no successor yet (process kill without - // an immediate reload). `.attached` promises a usable current - // page, so stop presenting the destroyed page's state and wait - // for the next page target to appear. - currentPage = nil - resetCurrentPageLifecycleModels(isolation: isolation) - if state == .attached { - transition(to: .attaching) - } - replacement = try await proxy.waitForCurrentPageReplacement(gracePeriod: nil) - guard Task.isCancelled == false, isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - } - guard let replacement else { - fail(.disconnected("Current page target was destroyed without a replacement.")) - return - } - currentPage = replacement - resetCurrentPageLifecycleModels(isolation: isolation) - await retargetCurrentPage(replacement, generation: generation, isolation: isolation) - } catch is CancellationError { - return - } catch { - guard isCurrentPageGeneration(generation, isolation: isolation) else { - return - } - failIfTerminal(error, operation: "current page replacement") - } - } -} - -extension WebInspectorContext { - func apply(_ event: DOM.Event, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - switch event { - case .documentUpdated: - advanceDOMDocumentGeneration(isolation: isolation) - resetDOM(isolation: isolation) - guard state != .attaching else { - return - } - reloadDocument(isolation: isolation) - case let .setChildNodes(parent, nodes): - applySetChildNodes(parent: parent, nodes: nodes, isolation: isolation) - case let .childNodeInserted(parent, previous, node): - applyChildNodeInserted(parent: parent, previous: previous, node: node, isolation: isolation) - case let .childNodeRemoved(parent, node): - applyChildNodeRemoved(parent: parent, node: node, isolation: isolation) - case let .childNodeCountUpdated(id, count): - guard let node = nodesByID[DOMNode.ID(id)] else { - let nodeID = DOMNode.ID(id) - loadFrameDocumentIfNeeded(forNodeID: nodeID, reason: "DOM.childNodeCountUpdated", isolation: isolation) - skipEvent("DOM.childNodeCountUpdated referenced unmaterialized node id=\(logDescription(nodeID))") - return - } - node.updateChildNodeCount(count) - notifyDOMTreeChildCountChanged(node: node, isolation: isolation) - case let .attributeModified(id, name, value): - guard let node = nodesByID[DOMNode.ID(id)] else { - let nodeID = DOMNode.ID(id) - loadFrameDocumentIfNeeded(forNodeID: nodeID, reason: "DOM.attributeModified", isolation: isolation) - skipEvent("DOM.attributeModified referenced unmaterialized node id=\(logDescription(nodeID))") - return - } - node.setAttribute(name: name, value: value) - markSelectedStylesNeedsRefresh(for: DOMNode.ID(id)) - notifyDOMTreeNodeChanged(node, isolation: isolation) - case let .attributeRemoved(id, name): - guard let node = nodesByID[DOMNode.ID(id)] else { - let nodeID = DOMNode.ID(id) - loadFrameDocumentIfNeeded(forNodeID: nodeID, reason: "DOM.attributeRemoved", isolation: isolation) - skipEvent("DOM.attributeRemoved referenced unmaterialized node id=\(logDescription(nodeID))") - return - } - node.removeAttribute(name: name) - markSelectedStylesNeedsRefresh(for: DOMNode.ID(id)) - notifyDOMTreeNodeChanged(node, isolation: isolation) - case let .inlineStyleInvalidated(ids): - if ids.isEmpty { - markSelectedStylesNeedsRefresh() - } else { - for id in ids { - markSelectedStylesNeedsRefresh(for: DOMNode.ID(id)) - } - } - case let .characterDataModified(id, value): - guard let node = nodesByID[DOMNode.ID(id)] else { - let nodeID = DOMNode.ID(id) - loadFrameDocumentIfNeeded(forNodeID: nodeID, reason: "DOM.characterDataModified", isolation: isolation) - skipEvent("DOM.characterDataModified referenced unmaterialized node id=\(logDescription(nodeID))") - return - } - node.setNodeValue(value) - markSelectedStylesNeedsRefresh(for: DOMNode.ID(id)) - notifyDOMTreeNodeChanged(node, isolation: isolation) - case let .inspect(id): - isElementPickerEnabled = false - notifyStatusChanged() - let inspectedNodeID = DOMNode.ID(id) - guard let node = nodesByID[inspectedNodeID] else { - loadFrameDocumentIfNeeded(forNodeID: inspectedNodeID, reason: "DOM.inspect", isolation: isolation) - WebInspectorDataKitLog.debug( - "DOM.inspect pending nodeID=\(String(describing: inspectedNodeID)) materialized=false root=\(String(describing: rootNode?.id))" - ) - pendingInspectedNodeID = inspectedNodeID - resolvePendingInspectedNode(requestSubtreeIfNeeded: true, isolation: isolation) - return - } - guard isNodeAttachedToCurrentDOMTree(node) else { - loadFrameDocumentIfNeeded(forNodeID: inspectedNodeID, reason: "DOM.inspect", isolation: isolation) - pendingInspectedNodeID = inspectedNodeID - resolvePendingInspectedNode(requestSubtreeIfNeeded: true, isolation: isolation) - return - } - WebInspectorDataKitLog.debug( - "DOM.inspect selecting nodeID=\(String(describing: inspectedNodeID)) materialized=true" - ) - pendingInspectedNodeID = nil - inspectResolutionTask?.cancel() - inspectResolutionTask = nil - selectInspectedNode(node, isolation: isolation) - case .detachedRoot: - // Deferred by design: detached roots need a registry and selection - // policy outside the single connected tree (05-two-layer-sdk-design - // §detached roots). Logged so a stalled inspect() is observable. - skipEvent("DOM.setChildNodes detached root deferred; subtree not indexed") - case let .shadowRootPushed(host, root): - applyShadowRootPushed(host: host, root: root, isolation: isolation) - case let .shadowRootPopped(host, root): - applyShadowRootPopped(host: host, root: root, isolation: isolation) - case let .pseudoElementAdded(parent, element): - applyPseudoElementAdded(parent: parent, element: element, isolation: isolation) - case let .pseudoElementRemoved(parent, element): - applyPseudoElementRemoved(parent: parent, element: element, isolation: isolation) - case .willDestroyDOMNode, - .unknown: - break - } - } - - func applyDocument( - _ node: DOM.Node, - reason: DOMTreeSnapshotReason = .initialDocument, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - var materializedPayloadIDs = Set() - collectMaterializedPayloadIDs(node, into: &materializedPayloadIDs) - rootNode = model(for: node, preserving: materializedPayloadIDs, isolation: isolation) - notifyDOMTreeSnapshot(reason: reason, isolation: isolation) - resolvePendingInspectedNode(requestSubtreeIfNeeded: true, isolation: isolation) - } - - package func seedDOMDocument(_ node: DOM.Node, isolation: isolated (any Actor) = #isolation) { - applyDocument( - node, - reason: rootNode == nil ? .initialDocument : .documentUpdated, - isolation: isolation - ) - } - - package func seedElementPickerEnabled(_ isEnabled: Bool, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - isElementPickerEnabled = isEnabled - notifyStatusChanged() - } - - /// Seeds the selected element node's styles through the same load path - /// the backend refresh uses. Requires a selected element node; cancels - /// any in-flight backend refresh so it cannot clobber the seeded state. - package func seedSelectedNodeStyles( - matchedStyles: CSS.MatchedStyles, - inlineStyles: CSS.InlineStyles? = nil, - computedProperties: [CSS.ComputedProperty] = [], - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - guard let selectedNode else { - preconditionFailure("seedSelectedNodeStyles requires a selected node.") - } - guard selectedNode.nodeType == 1 else { - preconditionFailure("seedSelectedNodeStyles requires a selected element node.") - } - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - let styles = selectedNode.elementStyles ?? CSSStyles(nodeID: selectedNode.id, modelContext: self) - selectedNode.setElementStyles(styles) - styles.load( - matchedStyles: matchedStyles, - inlineStyles: inlineStyles ?? CSS.InlineStyles(), - computedProperties: computedProperties - ) - } - - private func resetDOM(isolation: isolated (any Actor)) { - inspectResolutionTask?.cancel() - inspectResolutionTask = nil - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - inspectedNodeHighlightTask?.cancel() - inspectedNodeHighlightTask = nil - clearPageHighlightForDOMReset(isolation: isolation) - cancelFrameDocumentLoadTasks() - rootNode = nil - selectedNode = nil - if domEditHistoryTarget != nil { - didInvalidateDOMEditHistoryTarget = true - } - domEditHistoryTarget = nil - isElementPickerEnabled = false - pendingInspectedNodeID = nil - nodesByID = [:] - frameDocumentProjectionIndex.removeAll() - notifyDOMTreeSnapshot(reason: .reset, isolation: isolation) - notifyStatusChanged() - } - - private func recordPageHighlight( - documentGeneration: Int, - isolation: isolated (any Actor) - ) { - _ = isolation - pageHighlightDocumentGeneration = documentGeneration - } - - private func clearPageHighlightForDOMReset(isolation: isolated (any Actor)) { - guard let currentPage else { - return - } - guard pageHighlightDocumentGeneration != nil else { - return - } - pageHighlightDocumentGeneration = nil - inspectedNodeHighlightTask = Task { [weak self, currentPage] in - _ = isolation - do { - guard Task.isCancelled == false else { - return - } - guard self?.shouldSendPageHighlightClearAfterDOMReset(isolation: isolation) == true else { - return - } - WebInspectorDataKitLog.debug("DOM reset clearing page highlight") - try await currentPage.dom.hideHighlight() - } catch is CancellationError { - return - } catch { - self?.failIfTerminal(error, operation: "DOM.hideHighlight after DOM reset") - } - } - } - - private func shouldSendPageHighlightClearAfterDOMReset( - isolation: isolated (any Actor) - ) -> Bool { - _ = isolation - return pageHighlightDocumentGeneration == nil - } - - private func applySetChildNodes( - parent: DOM.Node.ID, - nodes: [DOM.Node], - isolation: isolated (any Actor) = #isolation - ) { - guard let parentNode = nodesByID[DOMNode.ID(parent)] else { - let parentID = DOMNode.ID(parent) - loadFrameDocumentIfNeeded(forNodeID: parentID, reason: "DOM.setChildNodes", isolation: isolation) - skipEvent("DOM.setChildNodes referenced unmaterialized parent id=\(logDescription(parentID))") - return - } - let previousChildren: [DOMNode] - if case let .loaded(children) = parentNode.children { - previousChildren = children - } else { - previousChildren = [] - } - var newSubtreeIDs = Set() - for node in nodes { - collectMaterializedPayloadIDs(node, into: &newSubtreeIDs) - } - let newChildren = nodes.map { model(for: $0, preserving: newSubtreeIDs, isolation: isolation) } - let newChildIDs = Set(newChildren.map(\.id)) - for previousChild in previousChildren where newChildIDs.contains(previousChild.id) == false { - removeSubtreeFromIndex(previousChild, preserving: newSubtreeIDs, isolation: isolation) - } - parentNode.setChildren(newChildren) - notifyDOMTreeChildrenReplaced(parent: parentNode, isolation: isolation) - resolvePendingInspectedNode(requestSubtreeIfNeeded: false, isolation: isolation) - } - - private func applyChildNodeInserted( - parent: DOM.Node.ID, - previous: DOM.Node.ID?, - node: DOM.Node, - isolation: isolated (any Actor) = #isolation - ) { - guard let parentNode = nodesByID[DOMNode.ID(parent)] else { - let parentID = DOMNode.ID(parent) - loadFrameDocumentIfNeeded(forNodeID: parentID, reason: "DOM.childNodeInserted", isolation: isolation) - skipEvent("DOM.childNodeInserted referenced unmaterialized parent id=\(logDescription(parentID))") - return - } - - guard case var .loaded(children) = parentNode.children else { - parentNode.updateChildNodeCount(parentNode.childNodeCount + 1) - notifyDOMTreeChildCountChanged(node: parentNode, isolation: isolation) - return - } - var materializedPayloadIDs = Set() - collectMaterializedPayloadIDs(node, into: &materializedPayloadIDs) - let inserted = model(for: node, preserving: materializedPayloadIDs, isolation: isolation) - if let previous, let index = children.firstIndex(where: { $0.id == DOMNode.ID(previous) }) { - children.insert(inserted, at: children.index(after: index)) - } else { - children.insert(inserted, at: 0) - } - parentNode.setChildren(children) - notifyDOMTreeChildInserted( - parent: parentNode, - node: inserted, - previousSiblingID: previous.map(DOMNode.ID.init), - isolation: isolation - ) - resolvePendingInspectedNode(requestSubtreeIfNeeded: false, isolation: isolation) - } - - private func applyChildNodeRemoved( - parent: DOM.Node.ID, - node: DOM.Node.ID, - isolation: isolated (any Actor) - ) { - guard let parentNode = nodesByID[DOMNode.ID(parent)] else { - let parentID = DOMNode.ID(parent) - loadFrameDocumentIfNeeded(forNodeID: parentID, reason: "DOM.childNodeRemoved", isolation: isolation) - skipEvent("DOM.childNodeRemoved referenced unmaterialized parent id=\(logDescription(parentID))") - return - } - - let removedID = DOMNode.ID(node) - guard let removedNode = nodesByID[removedID] else { - loadFrameDocumentIfNeeded(forNodeID: removedID, reason: "DOM.childNodeRemoved", isolation: isolation) - skipEvent("DOM.childNodeRemoved referenced unmaterialized child id=\(logDescription(removedID))") - return - } - removeSubtreeFromIndex(removedNode, isolation: isolation) - - guard case let .loaded(children) = parentNode.children else { - parentNode.updateChildNodeCount(max(0, parentNode.childNodeCount - 1)) - notifyDOMTreeChildCountChanged(node: parentNode, isolation: isolation) - return - } - parentNode.setChildren(children.filter { $0.id != removedID }) - notifyDOMTreeChildRemoved(parent: parentNode, nodeID: removedID, isolation: isolation) - } - - private func applyShadowRootPushed( - host: DOM.Node.ID, - root payload: DOM.Node, - isolation: isolated (any Actor) - ) { - guard let hostNode = nodesByID[DOMNode.ID(host)] else { - let hostID = DOMNode.ID(host) - loadFrameDocumentIfNeeded(forNodeID: hostID, reason: "DOM.shadowRootPushed", isolation: isolation) - skipEvent("DOM.shadowRootPushed referenced unmaterialized host id=\(logDescription(hostID))") - return - } - - var materializedPayloadIDs = Set() - collectMaterializedPayloadIDs(payload, into: &materializedPayloadIDs) - let rootNode = model(for: payload, preserving: materializedPayloadIDs, isolation: isolation) - hostNode.appendShadowRoot(rootNode) - notifyDOMTreeChildrenReplaced(parent: hostNode, isolation: isolation) - resolvePendingInspectedNode(requestSubtreeIfNeeded: false, isolation: isolation) - } - - private func applyShadowRootPopped( - host: DOM.Node.ID, - root: DOM.Node.ID, - isolation: isolated (any Actor) - ) { - guard let hostNode = nodesByID[DOMNode.ID(host)] else { - let hostID = DOMNode.ID(host) - loadFrameDocumentIfNeeded(forNodeID: hostID, reason: "DOM.shadowRootPopped", isolation: isolation) - skipEvent("DOM.shadowRootPopped referenced unmaterialized host id=\(logDescription(hostID))") - return - } - - let rootID = DOMNode.ID(root) - guard let removedRoot = hostNode.removeShadowRoot(id: rootID) ?? nodesByID[rootID] else { - skipEvent("DOM.shadowRootPopped referenced unmaterialized root id=\(logDescription(rootID))") - return - } - removeSubtreeFromIndex(removedRoot, isolation: isolation) - notifyDOMTreeChildrenReplaced(parent: hostNode, isolation: isolation) - } - - private func applyPseudoElementAdded( - parent: DOM.Node.ID, - element payload: DOM.Node, - isolation: isolated (any Actor) - ) { - guard let parentNode = nodesByID[DOMNode.ID(parent)] else { - let parentID = DOMNode.ID(parent) - loadFrameDocumentIfNeeded(forNodeID: parentID, reason: "DOM.pseudoElementAdded", isolation: isolation) - skipEvent("DOM.pseudoElementAdded referenced unmaterialized parent id=\(logDescription(parentID))") - return - } - - var materializedPayloadIDs = Set() - collectMaterializedPayloadIDs(payload, into: &materializedPayloadIDs) - let pseudoElement = model(for: payload, preserving: materializedPayloadIDs, isolation: isolation) - if let replacedElement = parentNode.setPseudoElement(pseudoElement) { - removeSubtreeFromIndex(replacedElement, preserving: materializedPayloadIDs, isolation: isolation) - } - notifyDOMTreeChildrenReplaced(parent: parentNode, isolation: isolation) - resolvePendingInspectedNode(requestSubtreeIfNeeded: false, isolation: isolation) - } - - private func applyPseudoElementRemoved( - parent: DOM.Node.ID, - element: DOM.Node.ID, - isolation: isolated (any Actor) - ) { - guard let parentNode = nodesByID[DOMNode.ID(parent)] else { - let parentID = DOMNode.ID(parent) - loadFrameDocumentIfNeeded(forNodeID: parentID, reason: "DOM.pseudoElementRemoved", isolation: isolation) - skipEvent("DOM.pseudoElementRemoved referenced unmaterialized parent id=\(logDescription(parentID))") - return - } - - let elementID = DOMNode.ID(element) - guard let removedElement = parentNode.removePseudoElement(id: elementID) ?? nodesByID[elementID] else { - skipEvent( - "DOM.pseudoElementRemoved referenced unmaterialized pseudo element id=\(logDescription(elementID))" - ) - return - } - removeSubtreeFromIndex(removedElement, isolation: isolation) - notifyDOMTreeChildrenReplaced(parent: parentNode, isolation: isolation) - } - - @discardableResult - private func removeSubtreeFromIndex( - _ root: DOMNode, - preserving preservedIDs: Set = [], - isolation: isolated (any Actor) - ) -> Bool { - var removedIDs = Set() - collectSubtreeIDs(root, into: &removedIDs) - removedIDs.subtract(preservedIDs) - frameDocumentProjectionIndex.removeProjections(containing: removedIDs) - for id in removedIDs { - nodesByID[id] = nil - } - if let selectedNode, removedIDs.contains(selectedNode.id) { - styleRefreshTask?.cancel() - styleRefreshTask = nil - styleRefreshGeneration += 1 - self.selectedNode = nil - notifyDOMTreeSelectionChanged(nil, isolation: isolation) - notifyStatusChanged() - return true - } - return false - } - - private func collectSubtreeIDs(_ node: DOMNode, into ids: inout Set) { - ids.insert(node.id) - for associatedRoot in node.associatedSubtreeRoots() { - collectSubtreeIDs(associatedRoot, into: &ids) - } - guard case let .loaded(children) = node.children else { - return - } - for child in children { - collectSubtreeIDs(child, into: &ids) - } - } - - private func collectMaterializedPayloadIDs(_ node: DOM.Node, into ids: inout Set) { - ids.insert(DOMNode.ID(node.id)) - for associatedNode in associatedPayloadNodes(for: node) { - collectMaterializedPayloadIDs(associatedNode, into: &ids) - } - for child in node.children ?? [] { - collectMaterializedPayloadIDs(child, into: &ids) - } - } - - private func model( - for payload: DOM.Node, - preserving materializedPayloadIDs: Set, - isolation: isolated (any Actor) - ) -> DOMNode { - let id = DOMNode.ID(payload.id) - let node: DOMNode - let previousChildren: [DOMNode] - let previousAssociatedRoots: [DOMNode] - if let existing = nodesByID[id] { - if case let .loaded(children) = existing.children { - previousChildren = children - } else { - previousChildren = [] - } - previousAssociatedRoots = existing.associatedSubtreeRoots() - existing.update(from: payload) - existing.setModelContext(self) - node = existing - } else { - previousChildren = [] - previousAssociatedRoots = [] - node = DOMNode(node: payload, modelContext: self) - nodesByID[id] = node - } - - let payloadContentDocument = payload.contentDocument.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let shadowRoots = payload.shadowRoots.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let templateContent = payload.templateContent.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let beforePseudoElement = payload.beforePseudoElement.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let otherPseudoElements = payload.otherPseudoElements.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let afterPseudoElement = payload.afterPseudoElement.map { - model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) - } - let contentDocument = projectedFrameDocument(for: node, payloadContentDocument: payloadContentDocument) - node.setAssociatedNodes( - contentDocument: contentDocument, - shadowRoots: shadowRoots, - templateContent: templateContent, - beforePseudoElement: beforePseudoElement, - otherPseudoElements: otherPseudoElements, - afterPseudoElement: afterPseudoElement - ) - let associatedIDs = Set(node.associatedSubtreeRoots().map(\.id)) - for previousRoot in previousAssociatedRoots where associatedIDs.contains(previousRoot.id) == false { - removeSubtreeFromIndex(previousRoot, preserving: materializedPayloadIDs, isolation: isolation) - } - - if let children = payload.children { - let newChildren = children.map { model(for: $0, preserving: materializedPayloadIDs, isolation: isolation) } - let newChildIDs = Set(newChildren.map(\.id)) - for previousChild in previousChildren where newChildIDs.contains(previousChild.id) == false { - removeSubtreeFromIndex(previousChild, preserving: materializedPayloadIDs, isolation: isolation) - } - node.setChildren(newChildren) - } else if payload.childNodeCount == 0 && previousChildren.isEmpty == false { - for previousChild in previousChildren { - removeSubtreeFromIndex(previousChild, preserving: materializedPayloadIDs, isolation: isolation) - } - node.setChildrenUnrequested(count: payload.childNodeCount) - } else { - node.updateChildNodeCount(payload.childNodeCount) - } - return node - } - - private func associatedPayloadNodes(for node: DOM.Node) -> [DOM.Node] { - [node.contentDocument] - .compactMap { $0 } - + node.shadowRoots - + [node.templateContent, node.beforePseudoElement] - .compactMap { $0 } - + node.otherPseudoElements - + [node.afterPseudoElement] - .compactMap { $0 } - } - - private func projectedFrameDocument( - for owner: DOMNode, - payloadContentDocument: DOMNode? - ) -> DOMNode? { - guard owner.isFrameOwner else { - return payloadContentDocument - } - - if let attachedRootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: owner.id) { - guard let attachedRoot = nodesByID[attachedRootID], - frameOwner(owner, matchesFrameDocumentRoot: attachedRoot) else { - frameDocumentProjectionIndex.detachProjection(attachedTo: owner.id) - return payloadContentDocument - } - return attachedRoot - } - - guard let frameTargetID = frameTargetIDForFrameDocument(matching: owner), - let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), - let root = nodesByID[rootID] else { - return payloadContentDocument - } - frameDocumentProjectionIndex.attach(frameTargetID: frameTargetID, to: owner.id) - return root - } - - private func frameTargetIDForFrameDocument(matching owner: DOMNode) -> WebInspectorTarget.ID? { - let matches = frameDocumentProjectionIndex.frameTargetIDs.filter { frameTargetID in - guard frameDocumentProjectionIndex.ownerNodeID(for: frameTargetID) == nil, - let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), - let root = nodesByID[rootID] else { - return false - } - return frameOwner(owner, matchesFrameDocumentRoot: root) - } - guard matches.count <= 1 else { - WebInspectorDataKitLog.debug( - "frame document projection ambiguous owner=\(String(describing: owner.id))" - ) - return nil - } - return matches.first - } - - private func frameOwner(_ owner: DOMNode, matchesFrameDocumentRoot root: DOMNode) -> Bool { - guard owner.isFrameOwner, - let ownerFrameID = owner.frameID, - let rootFrameID = root.frameID else { - return false - } - return ownerFrameID == rootFrameID - } - - private func loadFrameDocumentIfNeeded( - forNodeID nodeID: DOMNode.ID, - reason: String, - isolation: isolated (any Actor) - ) { - guard let frameTargetID = frameTargetID(for: nodeID) else { - return - } - if let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), - let root = nodesByID[rootID] { - if let owner = attachProjectedFrameDocumentRoot(root, frameTargetID: frameTargetID) { - notifyDOMTreeChildrenReplaced(parent: owner, isolation: isolation) - } - return - } - loadFrameDocumentIfNeeded(forFrameTargetID: frameTargetID, reason: reason, isolation: isolation) - } - - private func loadFrameDocumentIfNeeded( - forFrameTargetID frameTargetID: WebInspectorTarget.ID, - reason: String, - isolation: isolated (any Actor) - ) { - guard frameDocumentLoadTasks[frameTargetID] == nil else { - return - } - let generation = domDocumentGeneration - let target = proxy.frameTarget(id: frameTargetID) - WebInspectorDataKitLog.debug( - "frame document projection loading target=\(frameTargetID.rawValue) reason=\(reason)" - ) - frameDocumentLoadTasks[frameTargetID] = Task { [weak self, target, frameTargetID, generation] in - _ = isolation - do { - let document = try await target.dom.getDocument() - guard Task.isCancelled == false, - self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { - return - } - self?.applyFrameDocument(document, frameTargetID: frameTargetID, isolation: isolation) - } catch is CancellationError { - return - } catch { - guard self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { - return - } - self?.failIfTerminal(error, operation: "frame DOM.getDocument") - } - if self?.isDOMDocumentGeneration(generation, isolation: isolation) == true { - self?.frameDocumentLoadTasks[frameTargetID] = nil - } - } - } - - private func applyFrameDocument( - _ document: DOM.Node, - frameTargetID: WebInspectorTarget.ID, - isolation: isolated (any Actor) - ) { - let scopedDocument = scopedFrameDocument(document, to: frameTargetID) - var materializedPayloadIDs = Set() - collectMaterializedPayloadIDs(scopedDocument, into: &materializedPayloadIDs) - let previousRootID = frameDocumentProjectionIndex.setFrameDocumentRootID( - DOMNode.ID(scopedDocument.id), - for: frameTargetID - ) - let frameRoot = model(for: scopedDocument, preserving: materializedPayloadIDs, isolation: isolation) - if let previousRootID, - previousRootID != frameRoot.id, - let previousRoot = nodesByID[previousRootID] { - removeSubtreeFromIndex(previousRoot, preserving: materializedPayloadIDs, isolation: isolation) - } - - if let owner = attachProjectedFrameDocumentRoot(frameRoot, frameTargetID: frameTargetID) { - notifyDOMTreeChildrenReplaced(parent: owner, isolation: isolation) - } - resolvePendingInspectedNode(requestSubtreeIfNeeded: true, isolation: isolation) - } - - private func attachProjectedFrameDocumentRoot( - _ frameRoot: DOMNode, - frameTargetID: WebInspectorTarget.ID - ) -> DOMNode? { - guard let owner = frameOwner(forFrameDocumentRoot: frameRoot, frameTargetID: frameTargetID) else { - frameDocumentProjectionIndex.detach(frameTargetID: frameTargetID) - return nil - } - frameDocumentProjectionIndex.attach(frameTargetID: frameTargetID, to: owner.id) - owner.setContentDocument(frameRoot) - return owner - } - - private func frameOwner( - forFrameDocumentRoot frameRoot: DOMNode, - frameTargetID: WebInspectorTarget.ID - ) -> DOMNode? { - if let ownerID = frameDocumentProjectionIndex.ownerNodeID(for: frameTargetID), - let owner = nodesByID[ownerID], - frameOwner(owner, matchesFrameDocumentRoot: frameRoot) { - return owner - } - let candidates = nodesByID.values.filter { node in - guard frameOwner(node, matchesFrameDocumentRoot: frameRoot) else { - return false - } - guard let attachedRootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: node.id) else { - return true - } - return attachedRootID == frameRoot.id - } - guard candidates.count <= 1 else { - WebInspectorDataKitLog.debug( - "frame document projection ambiguous frameID=\(String(describing: frameRoot.frameID))" - ) - return nil - } - return candidates.first - } - - private func detachProjectedFrameDocument( - forFrameID frameID: FrameID, - isolation: isolated (any Actor) - ) { - let owners = nodesByID.values.filter { $0.isFrameOwner && $0.frameID == frameID } - for owner in owners { - guard let rootID = frameDocumentProjectionIndex.projectedFrameDocumentRootID(forOwnerNodeID: owner.id) else { - continue - } - let root = nodesByID[rootID] - frameDocumentProjectionIndex.detachProjection(attachedTo: owner.id) - owner.setContentDocument(nil) - if let root { - removeSubtreeFromIndex(root, isolation: isolation) - } - notifyDOMTreeChildrenReplaced(parent: owner, isolation: isolation) - } - } - - private func frameTargetID(for nodeID: DOMNode.ID) -> WebInspectorTarget.ID? { - nodeID.proxyID.targetScopeRawValue.map(WebInspectorTarget.ID.init) - } - - private func scopedFrameDocument(_ node: DOM.Node, to frameTargetID: WebInspectorTarget.ID) -> DOM.Node { - DOM.Node( - id: scopedNodeID(node.id, to: frameTargetID), - nodeType: node.nodeType, - nodeName: node.nodeName, - localName: node.localName, - nodeValue: node.nodeValue, - frameID: node.frameID, - documentURL: node.documentURL, - baseURL: node.baseURL, - attributes: node.attributes, - attributeList: node.attributeList, - childNodeCount: node.childNodeCount, - children: node.children?.map { scopedFrameDocument($0, to: frameTargetID) }, - contentDocument: node.contentDocument.map { scopedFrameDocument($0, to: frameTargetID) }, - shadowRoots: node.shadowRoots.map { scopedFrameDocument($0, to: frameTargetID) }, - templateContent: node.templateContent.map { scopedFrameDocument($0, to: frameTargetID) }, - beforePseudoElement: node.beforePseudoElement.map { scopedFrameDocument($0, to: frameTargetID) }, - otherPseudoElements: node.otherPseudoElements.map { scopedFrameDocument($0, to: frameTargetID) }, - afterPseudoElement: node.afterPseudoElement.map { scopedFrameDocument($0, to: frameTargetID) }, - pseudoType: node.pseudoType, - shadowRootType: node.shadowRootType - ) - } - - private func scopedNodeID(_ id: DOM.Node.ID, to frameTargetID: WebInspectorTarget.ID) -> DOM.Node.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return DOM.Node.ID(id.rawValue, scopedToTargetRawValue: frameTargetID.rawValue) - } - - private func cancelFrameDocumentLoadTasks() { - for task in frameDocumentLoadTasks.values { - task.cancel() - } - frameDocumentLoadTasks = [:] - } - - private func resolvePendingInspectedNode( - requestSubtreeIfNeeded: Bool, - isolation: isolated (any Actor) = #isolation - ) { - guard let pendingInspectedNodeID else { - return - } - guard let inspectedNode = nodesByID[pendingInspectedNodeID] else { - if requestSubtreeIfNeeded { - requestMaterializationForPendingInspectedNode(pendingInspectedNodeID, isolation: isolation) - } - return - } - guard isNodeAttachedToCurrentDOMTree(inspectedNode) else { - if requestSubtreeIfNeeded { - requestMaterializationForPendingInspectedNode(pendingInspectedNodeID, isolation: isolation) - } - return - } - WebInspectorDataKitLog.debug( - "DOM.inspect resolved pending nodeID=\(String(describing: pendingInspectedNodeID))" - ) - self.pendingInspectedNodeID = nil - inspectResolutionTask?.cancel() - inspectResolutionTask = nil - selectInspectedNode(inspectedNode, isolation: isolation) - } - - private func requestMaterializationForPendingInspectedNode( - _ nodeID: DOMNode.ID, - isolation: isolated (any Actor) - ) { - if let frameTargetID = frameTargetID(for: nodeID) { - if let rootID = frameDocumentProjectionIndex.frameDocumentRootID(for: frameTargetID), - let frameRoot = nodesByID[rootID] { - WebInspectorDataKitLog.debug( - "DOM.inspect unresolved nodeID=\(String(describing: nodeID)); reattaching frame document root" - ) - if let owner = attachProjectedFrameDocumentRoot(frameRoot, frameTargetID: frameTargetID) { - notifyDOMTreeChildrenReplaced(parent: owner, isolation: isolation) - } - } else { - loadFrameDocumentIfNeeded(forFrameTargetID: frameTargetID, reason: "DOM.inspect", isolation: isolation) - } - return - } - WebInspectorDataKitLog.debug( - "DOM.inspect unresolved nodeID=\(String(describing: nodeID)); waiting for DOM.requestNode path materialization" - ) - } - - private func selectInspectedNode(_ node: DOMNode, isolation: isolated (any Actor)) { - WebInspectorDataKitLog.debug("DOM.inspect selecting resolved nodeID=\(String(describing: node.id))") - select(node, isolation: isolation) - restoreElementPickerHighlight(for: node, isolation: isolation) - } - - private func restoreElementPickerHighlight(for node: DOMNode, isolation: isolated (any Actor)) { - guard let currentPage else { - skipEvent("DOM.inspect highlight restore ignored: no current page target") - return - } - let generation = domDocumentGeneration - let nodeID = node.id.proxyID - guard nodeID.targetScopeRawValue == nil else { - return - } - inspectedNodeHighlightTask?.cancel() - // Web Inspector clears the picker overlay after inspect. On touch devices - // WebInspectorKit keeps the picked node highlighted so the tap target remains visible. - inspectedNodeHighlightTask = Task { [weak self, currentPage, generation, nodeID] in - _ = isolation - do { - guard Task.isCancelled == false, - self?.isDOMDocumentGeneration(generation, isolation: isolation) == true else { - return - } - WebInspectorDataKitLog.debug( - "DOM.inspect restoring highlight nodeID=\(String(describing: nodeID))" - ) - self?.recordPageHighlight(documentGeneration: generation, isolation: isolation) - try await currentPage.dom.highlightNode(nodeID) - } catch is CancellationError { - return - } catch { - self?.failIfTerminal(error, operation: "DOM.highlightNode after inspect") - } - } - } -} - -extension WebInspectorContext { - private struct SelectedStylePayloads { - var matchedStyles: CSS.MatchedStyles - var inlineStyles: CSS.InlineStyles - var computedProperties: [CSS.ComputedProperty] - } - - func apply(_ event: CSS.Event, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - switch event { - case .styleSheetChanged, - .styleSheetAdded, - .styleSheetRemoved, - .mediaQueryResultChanged: - markSelectedStylesNeedsRefresh() - case let .nodeLayoutFlagsChanged(id): - markSelectedStylesNeedsRefresh(for: DOMNode.ID(id)) - case .unknown: - break - } - } - - private func refreshSelectedStyles(isolation: isolated (any Actor) = #isolation) { - styleRefreshTask?.cancel() - styleRefreshTask = nil - - guard let selectedNode else { - return - } - guard selectedNode.nodeType == 1 else { - selectedNode.setElementStyles(nil) - return - } - - let styles = selectedNode.elementStyles ?? CSSStyles(nodeID: selectedNode.id, modelContext: self) - selectedNode.setElementStyles(styles) - styles.markLoading() - styleRefreshGeneration += 1 - let generation = styleRefreshGeneration - styleRefreshTask = Task { [weak self, weak selectedNode, styles] in - _ = isolation - guard let self, let selectedNode else { - return - } - await self.loadStyles(for: selectedNode, into: styles, generation: generation, isolation: isolation) - } - } - - private func loadStyles( - for node: DOMNode, - into styles: CSSStyles, - generation: Int, - isolation: isolated (any Actor) = #isolation - ) async { - _ = isolation - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return - } - guard let currentPage else { - styles.markUnavailable() - return - } - - do { - guard let payloads = try await selectedStylePayloadsWithCSSAgentCompatibility( - for: node, - target: currentPage, - generation: generation, - isolation: isolation - ) else { return } - styles.load( - matchedStyles: payloads.matchedStyles, - inlineStyles: payloads.inlineStyles, - computedProperties: payloads.computedProperties - ) - } catch let error as WebInspectorProxyError { - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return - } - styles.fail(error) - } catch { - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return - } - styles.fail(.commandFailed( - domain: "CSS", - method: "getMatchedStylesForNode/getInlineStylesForNode/getComputedStyleForNode", - message: String(describing: error) - )) - } - } - - private func selectedStylePayloadsWithCSSAgentCompatibility( - for node: DOMNode, - target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) = #isolation - ) async throws -> SelectedStylePayloads? { - _ = isolation - do { - return try await selectedStylePayloads( - for: node, - target: target, - generation: generation, - isolation: isolation - ) - } catch let error as WebInspectorProxyError { - guard shouldRetrySelectedStyleLoadAfterEnablingCSSAgent(error) else { - throw error - } - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return nil - } - // Enable the CSS agent that rejected the style reads: for a - // frame-owned node that is the frame target, not the semantic - // current page the reads were retargeted away from. - try await domTarget(owning: node.id.proxyID).css.enable() - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return nil - } - return try await selectedStylePayloads( - for: node, - target: target, - generation: generation, - isolation: isolation - ) - } - } - - private func selectedStylePayloads( - for node: DOMNode, - target: WebInspectorTarget, - generation: Int, - isolation: isolated (any Actor) = #isolation - ) async throws -> SelectedStylePayloads? { - _ = isolation - let matchedStyles = try await target.css.matchedStyles(for: node.id.proxyID) - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return nil - } - let inlineStyles = try await target.css.inlineStyles(for: node.id.proxyID) - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return nil - } - let computedProperties = try await target.css.computedStyle(for: node.id.proxyID) - guard isCurrentStyleRefresh(node: node, generation: generation) else { - return nil - } - return SelectedStylePayloads( - matchedStyles: matchedStyles, - inlineStyles: inlineStyles, - computedProperties: computedProperties - ) - } - - private func shouldRetrySelectedStyleLoadAfterEnablingCSSAgent(_ error: WebInspectorProxyError) -> Bool { - guard case let .commandFailed(domain, method, message) = error, - domain == "CSS", - [ - "getMatchedStylesForNode", - "getInlineStylesForNode", - "getComputedStyleForNode", - ].contains(method) else { - return false - } - - return message.lowercased().contains("enable") - } - - private func isCurrentStyleRefresh(node: DOMNode, generation: Int) -> Bool { - Task.isCancelled == false && selectedNode === node && styleRefreshGeneration == generation - } - - /// Reports whether the style pane is visible. While active, stale - /// selected-node styles (`.needsRefresh`) re-fetch immediately; while - /// inactive they stay stale until the next activation or selection. - public func setStyleHydrationActive(_ active: Bool, isolation: isolated (any Actor) = #isolation) { - requireOwner(isolation) - guard isStyleHydrationActive != active else { - return - } - isStyleHydrationActive = active - guard active, let styles = selectedNode?.elementStyles else { - return - } - switch styles.phase { - case .needsRefresh, .failed: - refreshSelectedStyles(isolation: isolation) - case .loading, .loaded, .unavailable: - break - } - } - - package func styles(for nodeID: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> CSSStyles { - requireOwner(isolation) - let node = try requiredNode(for: nodeID, isolation: isolation) - guard node.nodeType == 1 else { - throw WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "getMatchedStylesForNode", - message: "CSS styles are only available for element DOM nodes." - ) - } - if selectedNode !== node { - select(node, isolation: isolation) - } - guard let styles = node.elementStyles else { - throw WebInspectorProxyError.disconnected("CSS styles were not created for the selected node.") - } - return styles - } - - package func refreshStyles(for nodeID: DOMNode.ID, isolation: isolated (any Actor) = #isolation) throws -> CSSStyles { - let styles = try styles(for: nodeID, isolation: isolation) - refreshSelectedStyles(isolation: isolation) - return styles - } - - package func setCSSProperty( - _ id: CSSStyleProperty.ID, - enabled: Bool, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, - let intent = styles.setStyleTextIntent(for: id, enabled: enabled) else { - throw WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "setStyleText", - message: "CSS property is stale, already mutating, or not editable." - ) - } - - let marker = Task {} - styleToggleTasks[id] = marker - defer { - marker.cancel() - styleToggleTasks[id] = nil - } - - let target = try cssTarget(owning: intent.styleID) - let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - styles.applySetStyleText(result: result, for: id) - refreshSelectedStylesIfHydrationActive(isolation: isolation) - } - - package func setCSSDeclarationText( - _ text: String, - for id: CSSStyleProperty.ID, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, - let intent = styles.setDeclarationTextIntent(for: id, text: text) else { - throw WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "setStyleText", - message: "CSS declaration is stale, already mutating, or not editable." - ) - } - - let marker = Task {} - styleToggleTasks[id] = marker - defer { - marker.cancel() - styleToggleTasks[id] = nil - } - - let target = try cssTarget(owning: intent.styleID) - let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - styles.applySetStyleText(result: result, for: id) - refreshSelectedStylesIfHydrationActive(isolation: isolation) - } - - package func setCSSRuleSelector( - _ selector: String, - for id: CSSStyleRule.ID, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let proxyID = id.proxyID - let target = try cssTarget(owning: proxyID) - _ = try await target.css.setRuleSelector(proxyID, selector: selector) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - refreshSelectedStylesIfHydrationActive(isolation: isolation) - } - - package func setCSSStyleSheetText( - _ text: String, - for id: CSS.StyleSheet.ID, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) async throws { - requireOwner(isolation) - let target = try cssTarget(owning: id) - try await target.css.setStyleSheetText(id, text: text) - recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - refreshSelectedStylesIfHydrationActive(isolation: isolation) - } - - /// Toggles a CSS declaration on or off by rewriting its owning style - /// text. Returns false without issuing a command when the property is - /// not currently editable (no selected styles, stale phase, read-only - /// section, or unrewritable style text), or when a toggle for the same - /// property is already in flight. - @discardableResult - public func requestSetCSSProperty( - _ id: CSSStyleProperty.ID, - enabled: Bool, - options: WebInspectorMutationOptions, - isolation: isolated (any Actor) = #isolation - ) -> Bool { - requireOwner(isolation) - guard styleToggleTasks[id] == nil, - let styles = selectedNode?.elementStyles, - let intent = styles.setStyleTextIntent(for: id, enabled: enabled) else { - return false - } - let target: WebInspectorTarget - do { - target = try cssTarget(owning: intent.styleID) - } catch { - failIfTerminal(error, operation: "CSS.setStyleText") - return false - } - - styleToggleTasks[id] = Task { [weak self, target, styles] in - _ = isolation - do { - let result = try await target.css.setStyleText(intent.styleID, text: intent.text) - guard let self else { - return - } - self.styleToggleTasks[id] = nil - guard Task.isCancelled == false else { - return - } - self.recordDOMEditHistoryTarget(target, options: options) - try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) - styles.applySetStyleText(result: result, for: id) - self.refreshSelectedStylesIfHydrationActive(isolation: isolation) - } catch is CancellationError { - self?.styleToggleTasks[id] = nil - } catch { - guard let self else { - return - } - self.styleToggleTasks[id] = nil - guard Task.isCancelled == false else { - return - } - self.failIfTerminal(error, operation: "CSS.setStyleText") - self.refreshSelectedStyles(isolation: isolation) - } - } - return true - } - - private func refreshSelectedStylesIfHydrationActive(isolation: isolated (any Actor) = #isolation) { - guard isStyleHydrationActive else { - return - } - refreshSelectedStyles(isolation: isolation) - } - - private func markSelectedStylesNeedsRefresh( - for nodeID: DOMNode.ID, - isolation: isolated (any Actor) = #isolation - ) { - guard selectedNode?.id == nodeID else { - return - } - markSelectedStylesNeedsRefresh(isolation: isolation) - } - - private func markSelectedStylesNeedsRefresh(isolation: isolated (any Actor) = #isolation) { - styleRefreshGeneration += 1 - guard let styles = selectedNode?.elementStyles else { - return - } - styles.markNeedsRefresh() - refreshSelectedStylesIfHydrationActive(isolation: isolation) - } -} - -extension WebInspectorContext { - @discardableResult - package func seedNetworkRequest( - requestID rawRequestID: String, - url: String, - method: String = "GET", - resourceTypeRawValue: String?, - requestHeaders: [String: String] = [:], - postData: String? = nil, - responseMIMEType: String, - responseStatus: Int, - responseStatusText: String, - responseHeaders: [String: String] = [:], - responseBody: String? = nil, - timestamp: Double, - encodedBodyLength: Int = 0, - isolation: isolated (any Actor) = #isolation - ) -> NetworkRequest.ID { - requireOwner(isolation) - let requestID = Network.Request.ID(rawRequestID) - let resourceType = resourceTypeRawValue.map(Network.ResourceType.init(rawValue:)) - let payload = Network.Request( - id: requestID, - url: url, - method: method, - headers: requestHeaders, - postData: postData - ) - let id = NetworkRequest.ID(requestID) - let request: NetworkRequest - let inserted: Bool - if let existing = requestsByID[id] { - request = existing - request.applyRequestWillBeSent( - request: payload, - resourceType: resourceType, - timestamp: timestamp - ) - inserted = false - } else { - request = NetworkRequest( - request: payload, - resourceType: resourceType, - timestamp: timestamp, - modelContext: self - ) - requestsByID[id] = request - appendNetworkRequestID(id) - inserted = true - } - request.applyResponse( - Network.Response( - url: url, - status: responseStatus, - statusText: responseStatusText, - mimeType: responseMIMEType, - headers: responseHeaders, - source: Network.Source(rawValue: "network"), - requestHeaders: requestHeaders - ), - resourceType: resourceType ?? .other, - timestamp: timestamp + 0.1 - ) - request.applyDataReceived( - dataLength: encodedBodyLength, - encodedDataLength: encodedBodyLength, - timestamp: timestamp + 0.11 - ) - request.finish( - timestamp: timestamp + 0.2, - sourceMapURL: nil, - metrics: Network.Metrics( - encodedDataLength: encodedBodyLength, - decodedBodyLength: encodedBodyLength - ) - ) - if let responseBody { - request.responseBody.load(Network.Body(data: responseBody, base64Encoded: false)) - } - networkRequestIndexNeedsRebuild = true - networkFetchedResults.removeAll { $0.value == nil } - if inserted { - networkCollectionState.didInsertRequest() - for registration in networkFetchedResults { - registration.value?.insertNetworkRequest( - request, - lookup: { id in self.requestsByID[id] } - ) - } - } else { - for registration in networkFetchedResults { - registration.value?.refreshNetworkRequestAfterMutation( - request, - lookup: { id in self.requestsByID[id] } - ) - } - } - return request.id - } - - package func seedResponseBody( - for requestID: NetworkRequest.ID, - body: String, - base64Encoded: Bool = false, - size: Int? = nil, - isTruncated: Bool = false, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - guard let request = requestsByID[requestID] else { - preconditionFailure("Cannot seed a response body for an unregistered NetworkRequest.") - } - request.responseBody.load(NetworkBody.Payload( - body: body, - base64Encoded: base64Encoded, - size: size, - isTruncated: isTruncated - )) - } - - package func apply(_ event: Network.Event, isolation: isolated (any Actor) = #isolation) async { - requireOwner(isolation) - switch event { - case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): - await applyRequestWillBeSent( - id: id, - request: request, - resourceType: resourceType, - redirectResponse: redirectResponse, - timestamp: timestamp, - isolation: isolation - ) - case let .responseReceived(id, response, resourceType, timestamp): - await applyResponseReceived( - id: id, - response: response, - resourceType: resourceType, - timestamp: timestamp, - isolation: isolation - ) - case let .dataReceived(id, dataLength, encodedDataLength, timestamp): - guard let request = networkRequest(for: id, method: "dataReceived") else { - return - } - request.applyDataReceived( - dataLength: dataLength, - encodedDataLength: encodedDataLength, - timestamp: timestamp - ) - await notifyNetworkRequestMutated(request, isolation: isolation) - case let .loadingFinished(id, timestamp, sourceMapURL, metrics): - guard let request = networkRequest(for: id, method: "loadingFinished") else { - return - } - request.finish(timestamp: timestamp, sourceMapURL: sourceMapURL, metrics: metrics) - await notifyNetworkRequestMutated(request, isolation: isolation) - case let .loadingFailed(id, errorText, canceled, timestamp): - guard let request = networkRequest(for: id, method: "loadingFailed") else { - return - } - request.fail(errorText: errorText, canceled: canceled, timestamp: timestamp) - await notifyNetworkRequestMutated(request, isolation: isolation) - case let .webSocket(event): - await apply(event, isolation: isolation) - case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): - await applyRequestServedFromMemoryCache( - id: id, - response: response, - resourceType: resourceType, - timestamp: timestamp, - isolation: isolation - ) - case .unknown: - break - } - } - - private func applyRequestWillBeSent( - id proxyID: Network.Request.ID, - request payload: Network.Request, - resourceType: Network.ResourceType?, - redirectResponse: Network.Response?, - timestamp: Double, - isolation: isolated (any Actor) - ) async { - _ = isolation - let id = NetworkRequest.ID(proxyID) - guard clearedNetworkRequestIDs.contains(id) == false || redirectResponse == nil else { - return - } - clearedNetworkRequestIDs.remove(id) - let request: NetworkRequest - var inserted = false - var topologyMayHaveChanged = false - if let existing = requestsByID[id] { - request = existing - if let redirectResponse, existing.isActive { - request.applyRedirect( - to: payload, - redirectResponse: redirectResponse, - timestamp: timestamp, - resourceType: resourceType - ) - topologyMayHaveChanged = true - } else if existing.isActive == false { - request.applyRequestWillBeSent(request: payload, resourceType: resourceType, timestamp: timestamp) - topologyMayHaveChanged = true - } - } else { - request = NetworkRequest(request: payload, resourceType: resourceType, timestamp: timestamp, modelContext: self) - requestsByID[id] = request - appendNetworkRequestID(id) - inserted = true - } - if inserted { - await notifyNetworkRequestInserted(request, isolation: isolation) - } else if topologyMayHaveChanged { - await notifyNetworkRequestMutated(request, isolation: isolation) - } - } - - private func applyRequestServedFromMemoryCache( - id proxyID: Network.Request.ID, - response: Network.Response, - resourceType: Network.ResourceType?, - timestamp: Double, - isolation: isolated (any Actor) - ) async { - _ = isolation - let id = NetworkRequest.ID(proxyID) - guard clearedNetworkRequestIDs.contains(id) == false else { - return - } - let request: NetworkRequest - if let existing = requestsByID[id] { - request = existing - } else { - guard let url = response.url else { - skipEvent("Network.requestServedFromMemoryCache omitted response URL for a new request") - return - } - let payload = Network.Request( - id: proxyID, - url: url, - method: "GET", - headers: response.requestHeaders ?? [:] - ) - request = NetworkRequest(request: payload, resourceType: resourceType, timestamp: timestamp, modelContext: self) - requestsByID[id] = request - appendNetworkRequestID(id) - request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) - await notifyNetworkRequestInserted(request, isolation: isolation) - return - } - request.applyMemoryCache(response: response, resourceType: resourceType, timestamp: timestamp) - await notifyNetworkRequestMutated(request, isolation: isolation) - } - - private func applyResponseReceived( - id proxyID: Network.Request.ID, - response: Network.Response, - resourceType: Network.ResourceType?, - timestamp: Double, - isolation: isolated (any Actor) - ) async { - _ = isolation - let id = NetworkRequest.ID(proxyID) - guard clearedNetworkRequestIDs.contains(id) == false else { - return - } - let request: NetworkRequest - var inserted = false - if let existing = requestsByID[id] { - request = existing - } else { - guard let url = response.url else { - skipEvent("Network.responseReceived omitted response URL for an untracked request") - return - } - // WebKit's frontend creates a resource here when inspection starts - // after Network.requestWillBeSent. The response event has no method, - // so keep the same GET default WebKit uses when serializing such a - // resource later. - let payload = Network.Request( - id: proxyID, - url: url, - method: "GET", - headers: response.requestHeaders ?? [:] - ) - request = NetworkRequest(request: payload, resourceType: resourceType, timestamp: timestamp, modelContext: self) - requestsByID[id] = request - appendNetworkRequestID(id) - inserted = true - } - request.applyResponse(response, resourceType: resourceType, timestamp: timestamp) - if inserted { - await notifyNetworkRequestInserted(request, isolation: isolation) - } else { - await notifyNetworkRequestMutated(request, isolation: isolation) - } - } - - private func apply(_ event: Network.WebSocketEvent, isolation: isolated (any Actor)) async { - _ = isolation - switch event { - case let .created(id, url): - await applyWebSocketCreated(id: id, url: url, isolation: isolation) - case let .handshakeRequest(id, request, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketWillSendHandshakeRequest") else { - return - } - networkRequest.applyWebSocketHandshakeRequest(request, timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case let .handshakeResponse(id, response, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketHandshakeResponseReceived") else { - return - } - networkRequest.applyWebSocketHandshakeResponse(response, timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case let .frameSent(id, frame, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketFrameSent") else { - return - } - networkRequest.appendWebSocketFrame(frame, direction: .sent, timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case let .frameReceived(id, frame, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketFrameReceived") else { - return - } - networkRequest.appendWebSocketFrame(frame, direction: .received, timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case let .error(id, message, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketFrameError") else { - return - } - networkRequest.appendWebSocketError(message, timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case let .closed(id, timestamp): - guard let networkRequest = networkRequest(for: id, method: "webSocketClosed") else { - return - } - networkRequest.closeWebSocket(timestamp: timestamp) - await notifyNetworkRequestMutated(networkRequest, isolation: isolation) - case .other: - break - } - } - - private func applyWebSocketCreated( - id proxyID: Network.Request.ID, - url: String, - isolation: isolated (any Actor) - ) async { - _ = isolation - let id = NetworkRequest.ID(proxyID) - clearedNetworkRequestIDs.remove(id) - let request: NetworkRequest - var inserted = false - if let existing = requestsByID[id] { - request = existing - } else { - let payload = Network.Request(id: proxyID, url: url, method: "GET") - request = NetworkRequest(request: payload, resourceType: .webSocket, timestamp: nil, modelContext: self) - requestsByID[id] = request - appendNetworkRequestID(id) - inserted = true - } - request.applyWebSocketCreated(url: url) - if inserted { - await notifyNetworkRequestInserted(request, isolation: isolation) - } else { - await notifyNetworkRequestMutated(request, isolation: isolation) - } - } - - private func networkRequest( - for proxyID: Network.Request.ID, - method: String - ) -> NetworkRequest? { - let id = NetworkRequest.ID(proxyID) - guard let request = requestsByID[id] else { - if clearedNetworkRequestIDs.contains(id) == false { - skipEvent("Network.\(method) referenced an untracked request") - } - return nil - } - return request - } - - private func clearNetworkRequests() { - clearedNetworkRequestIDs.formUnion(requestsByID.keys) - requestsByID = [:] - orderedRequestIDs = [] - networkRequestOrderIndicesByID = [:] - networkRequestIndexNeedsRebuild = true - clearNetworkRequestIndex() - networkCollectionState.replaceCount(0) - resetNetworkFetchedResults() - } - - private func currentNetworkRequests() -> [NetworkRequest] { - orderedRequestIDs.compactMap { requestsByID[$0] } - } - - private func appendNetworkRequestID(_ id: NetworkRequest.ID) { - networkRequestOrderIndicesByID[id] = orderedRequestIDs.count - orderedRequestIDs.append(id) - } - - private func clearNetworkRequestIndex() { - let index = networkRequestIndex - let sequence = nextNetworkRequestIndexSequence() - Task { - await index.replace(with: [], sequence: sequence) - } - } - - private func currentNetworkRecordInputs() -> [NetworkRequestRecordInput] { - orderedRequestIDs.enumerated().compactMap { index, id in - requestsByID[id].map { NetworkRequestRecordInput(request: $0, orderIndex: index) } - } - } - - private func networkRecordInput(for request: NetworkRequest) -> NetworkRequestRecordInput { - let orderIndex = networkRequestOrderIndicesByID[request.id] ?? orderedRequestIDs.count - return NetworkRequestRecordInput(request: request, orderIndex: orderIndex) - } - - private func isCurrentNetworkRequest(_ request: NetworkRequest) -> Bool { - requestsByID[request.id] === request - } - - private func nextNetworkRequestIndexSequence() -> UInt64 { - networkRequestIndexSequence &+= 1 - return networkRequestIndexSequence - } - - private func syncNetworkRequestIndexIfNeeded(isolation: isolated (any Actor)) async { - _ = isolation - guard networkRequestIndexNeedsRebuild else { - return - } - networkRequestIndexNeedsRebuild = false - let sequence = nextNetworkRequestIndexSequence() - await networkRequestIndex.replace(with: currentNetworkRecordInputs(), sequence: sequence) - } - - private func notifyNetworkRequestInserted( - _ request: NetworkRequest, - isolation: isolated (any Actor) - ) async { - _ = isolation - networkCollectionState.didInsertRequest() - await syncNetworkRequestIndexIfNeeded(isolation: isolation) - guard isCurrentNetworkRequest(request) else { - return - } - let sequence = nextNetworkRequestIndexSequence() - await networkRequestIndex.upsert(networkRecordInput(for: request), sequence: sequence) - guard isCurrentNetworkRequest(request) else { - return - } - await applyNetworkResultDeltas(for: request, inserted: true, isolation: isolation) - } - - private func notifyNetworkRequestMutated( - _ request: NetworkRequest, - isolation: isolated (any Actor) - ) async { - _ = isolation - await syncNetworkRequestIndexIfNeeded(isolation: isolation) - guard isCurrentNetworkRequest(request) else { - return - } - let sequence = nextNetworkRequestIndexSequence() - await networkRequestIndex.upsert(networkRecordInput(for: request), sequence: sequence) - guard isCurrentNetworkRequest(request) else { - return - } - await applyNetworkResultDeltas(for: request, inserted: false, isolation: isolation) - } - - private func applyNetworkResultDeltas( - for request: NetworkRequest, - inserted: Bool, - isolation: isolated (any Actor) - ) async { - _ = isolation - networkFetchedResults.removeAll { $0.value == nil } - for registration in networkFetchedResults { - guard let results = registration.value else { - continue - } - let plan = results.currentNetworkQueryPlan(context: self) - if plan.requiresModelPredicate { - if inserted { - results.insertNetworkRequest( - request, - lookup: { id in self.requestsByID[id] } - ) - } else { - results.refreshNetworkRequestAfterMutation( - request, - lookup: { id in self.requestsByID[id] } - ) - } - continue - } - let oldSnapshot = results.networkSnapshotForDelta - let resultTopologyRevision = results.topologyRevision - let indexSequence = networkRequestIndexSequence - guard let delta = await networkRequestIndex.delta( - plan: plan, - sectionBy: results.sectionBy, - oldSnapshot: oldSnapshot, - changedID: request.id - ) else { - continue - } - guard networkRequestIndexSequence == indexSequence, - results.topologyRevision == resultTopologyRevision, - results.networkSnapshotForDelta == oldSnapshot else { - continue - } - results.applyNetworkDelta(delta, lookup: { id in self.requestsByID[id] }) - } - } - - private func resetNetworkFetchedResults() { - networkFetchedResults.removeAll { $0.value == nil } - for registration in networkFetchedResults { - registration.value?.resetNetworkItems() - } - } -} - -extension WebInspectorContext { - func apply( - _ event: Console.Event, - targetID: WebInspectorTarget.ID? = nil, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - switch event { - case let .messageAdded(message): - applyMessageAdded(message, targetID: targetID) - case let .messageRepeatCountUpdated(count, timestamp): - let lastMessageID = targetID.flatMap { lastConsoleMessageIDByTargetID[$0] } ?? lastConsoleMessageID - guard let lastConsoleMessageID = lastMessageID, - let message = consoleMessagesByID[lastConsoleMessageID] else { - skipEvent("Console.messageRepeatCountUpdated arrived before any tracked message") - return - } - message.updateRepeatCount(count, timestamp: timestamp) - refreshAllConsoleMessages(updatedItemIDs: [message.id]) - case .messagesCleared: - clearConsoleMessages(targetID: targetID) - releaseConsoleRuntimeObjectGroup(targetID: targetID, isolation: isolation) - case .unknown: - break - } - } - - private func clearConsoleMessages(targetID: WebInspectorTarget.ID? = nil) { - guard let targetID else { - consoleMessagesByID = [:] - orderedConsoleMessageIDs = [] - lastConsoleMessageID = nil - lastConsoleMessageIDByTargetID = [:] - unregisterRuntimeObjects(owner: .console) - refreshAllConsoleMessages() - return - } - let removedMessages = consoleMessagesByID.values.filter { $0.targetID == targetID } - guard removedMessages.isEmpty == false else { - lastConsoleMessageIDByTargetID[targetID] = nil - refreshAllConsoleMessages() - return - } - let removedIDs = Set(removedMessages.map(\.id)) - for id in removedIDs { - consoleMessagesByID[id] = nil - } - orderedConsoleMessageIDs.removeAll { removedIDs.contains($0) } - if let lastConsoleMessageID, removedIDs.contains(lastConsoleMessageID) { - self.lastConsoleMessageID = orderedConsoleMessageIDs.last - } - lastConsoleMessageIDByTargetID[targetID] = orderedConsoleMessageIDs.last { id in - consoleMessagesByID[id]?.targetID == targetID - } - unregisterConsoleRuntimeObjectsIfUnreferenced(from: removedMessages) - refreshAllConsoleMessages() - } - - private func releaseConsoleRuntimeObjectGroup( - targetID: WebInspectorTarget.ID? = nil, - isolation: isolated (any Actor) = #isolation - ) { - let target: WebInspectorTarget - if let targetID { - target = proxy.frameTarget(id: targetID) - } else if let currentPage { - target = currentPage - } else { - skipEvent("Console.messagesCleared arrived without a current page target") - return - } - // Release tasks are tracked per target: a clear for one frame target - // must not cancel another target's still-pending release. - let key = targetID ?? .currentPage - consoleObjectGroupReleaseTasks[key]?.cancel() - consoleObjectGroupReleaseTasks[key] = Task { [weak self, target] in - _ = isolation - do { - try await target.runtime.releaseObjectGroup(.console) - } catch is CancellationError { - return - } catch { - self?.failIfTerminal(error, operation: "Runtime.releaseObjectGroup") - } - } - } - - private func cancelConsoleObjectGroupReleaseTasks() { - for task in consoleObjectGroupReleaseTasks.values { - task.cancel() - } - consoleObjectGroupReleaseTasks = [:] - } - - private func applyMessageAdded(_ payload: Console.Message, targetID: WebInspectorTarget.ID?) { - let id = ConsoleMessage.ID(nextConsoleMessageOrdinal) - nextConsoleMessageOrdinal += 1 - let parameters = payload.parameters.map { registerRuntimeObject($0, owner: .console) } - let message = ConsoleMessage( - id: id, - message: payload, - parameters: parameters, - targetID: targetID, - modelContext: self - ) - consoleMessagesByID[id] = message - orderedConsoleMessageIDs.append(id) - lastConsoleMessageID = id - if let targetID { - lastConsoleMessageIDByTargetID[targetID] = id - } - refreshAllConsoleMessages() - } - - private func unregisterConsoleRuntimeObjectsIfUnreferenced(from removedMessages: [ConsoleMessage]) { - let removedObjects = Set(removedMessages.flatMap(\.parameters).map(\.id)) - guard removedObjects.isEmpty == false else { - return - } - let remainingConsoleObjects = Set(consoleMessagesByID.values.flatMap(\.parameters).map(\.id)) - for objectID in removedObjects.subtracting(remainingConsoleObjects) { - guard let object = runtimeObjectsByID[objectID] else { - continue - } - unregisterRuntimeObject(object, owner: .console) - } - } - - private func currentConsoleMessages() -> [ConsoleMessage] { - orderedConsoleMessageIDs.compactMap { consoleMessagesByID[$0] } - } - - private func consoleMessages(for descriptor: WebInspectorFetchDescriptor) -> [ConsoleMessage] { - var items = currentConsoleMessages() - if let predicate = descriptor.predicate { - items = items.filter { message in - do { - return try predicate.evaluate(message) - } catch { - preconditionFailure("ConsoleMessage predicate evaluation failed: \(error)") - } - } - } - if descriptor.sortBy.isEmpty == false { - items.sort { lhs, rhs in - for sortDescriptor in descriptor.sortBy { - switch sortDescriptor.compare(lhs, rhs) { - case .orderedAscending: - return true - case .orderedDescending: - return false - case .orderedSame: - continue - } - } - return lhs.id < rhs.id - } - } - let lowerBound = min(descriptor.fetchOffset, items.count) - let upperBound: Int - if let fetchLimit = descriptor.fetchLimit { - upperBound = min(lowerBound + fetchLimit, items.count) - } else { - upperBound = items.count - } - return Array(items[lowerBound.. = []) { - consoleFetchedResults.removeAll { $0.value == nil } - for registration in consoleFetchedResults { - guard let results = registration.value else { - continue - } - results.setItems(consoleMessages(for: results.fetchDescriptor), updatedItemIDs: updatedItemIDs) - } - } -} - -extension WebInspectorContext { - func apply( - _ event: Runtime.Event, - targetID: WebInspectorTarget.ID? = nil, - isolation: isolated (any Actor) = #isolation - ) { - requireOwner(isolation) - switch event { - case let .executionContextCreated(context): - applyExecutionContextCreated(context) - case let .executionContextDestroyed(id): - applyExecutionContextDestroyed(id) - case let .executionContextsCleared(eventTargetID): - if eventTargetID == .currentPage || eventTargetID == targetID { - clearExecutionContexts() - } else { - clearExecutionContexts(targetID: eventTargetID) - } - case .unknown: - break - } - } - - private func applyExecutionContextCreated(_ payload: Runtime.ExecutionContext) { - let id = RuntimeContext.ID(payload.id) - if let context = runtimeContextsByID[id] { - context.update(from: payload) - } else { - let context = RuntimeContext(context: payload, modelContext: self) - runtimeContextsByID[id] = context - orderedRuntimeContextIDs.append(id) - } - refreshExecutionContexts() - if selectedContext == nil { - selectedContext = runtimeContextsByID[id] - } - } - - private func applyExecutionContextDestroyed(_ proxyID: Runtime.ExecutionContext.ID) { - let id = RuntimeContext.ID(proxyID) - guard let removed = runtimeContextsByID.removeValue(forKey: id) else { - skipEvent("Runtime.executionContextDestroyed referenced an untracked context") - return - } - orderedRuntimeContextIDs.removeAll { $0 == id } - if selectedContext === removed { - selectedContext = firstRuntimeContext() - } - refreshExecutionContexts() - } - - private func clearExecutionContexts() { - runtimeContextsByID = [:] - orderedRuntimeContextIDs = [] - executionContexts = [] - selectedContext = nil - clearRuntimeObjects() - } - - private func clearExecutionContexts(targetID: WebInspectorTarget.ID) { - let removedIDs = Set(runtimeContextsByID.keys.filter { id in - id.proxyID.targetScopeRawValue == targetID.rawValue - }) - guard removedIDs.isEmpty == false else { - return - } - runtimeContextsByID = runtimeContextsByID.filter { removedIDs.contains($0.key) == false } - orderedRuntimeContextIDs.removeAll { removedIDs.contains($0) } - if let selectedContext, removedIDs.contains(selectedContext.id) { - self.selectedContext = firstRuntimeContext() - } - refreshExecutionContexts() - clearRuntimeObjects(targetID: targetID) - } - - private func refreshExecutionContexts() { - executionContexts = orderedRuntimeContextIDs.compactMap { runtimeContextsByID[$0] } - } - - private func firstRuntimeContext() -> RuntimeContext? { - orderedRuntimeContextIDs.compactMap { runtimeContextsByID[$0] }.first - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md index 70b81160..2ac10b06 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md +++ b/Sources/WebInspectorDataKit/WebInspectorDataKit.docc/WebInspectorDataKit.md @@ -9,8 +9,9 @@ models instead of sending protocol commands directly. DataKit owns DOM, Network, Console, Runtime, and CSS model state for one inspected page and keeps those models updated as WebKit emits protocol events. -Attach a ``WebInspectorContainer`` to a `WKWebView`, then read the -``WebInspectorContext`` for UI-bound model state: +Create one ``WebInspectorModelContext`` for an inspector lifetime. The UIKit +convenience initializer attaches it to a `WKWebView` on the main actor and +returns only after the initial model synchronization boundary: ```swift import WebKit @@ -18,14 +19,12 @@ import WebInspectorDataKit @MainActor final class InspectorModel { - private var container: WebInspectorContainer? - private(set) var context: WebInspectorContext? + private(set) var context: WebInspectorModelContext? private var treeTask: Task? func attach(to webView: WKWebView) async throws { - let container = try await WebInspectorContainer(attachingTo: webView) - let context = container.mainContext - let tree = context.dom.treeController() + let context = try await WebInspectorModelContext(attachingTo: webView) + let tree = try context.domTree treeTask = Task { @MainActor in for await update in tree.updates { @@ -33,30 +32,32 @@ final class InspectorModel { } } - self.container = container self.context = context } func close() async { treeTask?.cancel() - await container?.close() - container = nil + await context?.close() context = nil } } ``` -Contexts are actor-owned. Read and mutate context state from the same actor you -used to create or obtain the context. For UIKit clients, ``WebInspectorContainer`` -provides ``WebInspectorContainer/mainContext`` as a main-actor context. +Contexts are actor-owned rather than main-actor-only. ``WebInspectorModelContext/attach(to:isolation:)`` +confines a context permanently to the caller's current actor by default. Read +observable state and invoke commands from that actor. The `WKWebView` +convenience initializer above deliberately binds the context to `MainActor`. -Use the domain controllers on ``WebInspectorContext`` for high-level operations: +Use the context directly for high-level operations. Runtime objects belong to +an explicit binding-scoped group whose cleanup is awaited: ```swift -try await context.dom.setInspectMode(enabled: true) -try await context.page.reload() +try await context.setElementPickerEnabled(true) +try await context.reload() -let result = try await context.runtime.evaluate("document.title") +let result = try await context.withRuntimeObjectGroup { group in + try await group.evaluate("document.title") +} print(result.object.description ?? "") ``` @@ -65,12 +66,39 @@ DOM edits, fetched-results style collection updates, or derived DOM tree snapshots. Reach for WebInspectorProxyKit when you need direct typed protocol access with no model layer. +Create live Network and Console collections with their closed query values. The +index actor evaluates filters, ordering, sections, and windows; the context's +owner actor only resolves the identities in the published window: + +```swift +let requests = try await context.networkRequests(matching: NetworkQuery( + search: "api.example.com", + resourceCategories: [.xhrFetch], + methods: ["GET", "POST"], + sort: .requestTimeDescending, + section: .method, + limit: 100 +)) + +try await requests.update(NetworkQuery( + resourceCategories: [.script], + sort: .requestTimeAscending +)) + +for await update in requests.updates() { + apply(update) +} +``` + ## Topics ### Creating a Model Context -- ``WebInspectorContainer`` -- ``WebInspectorContext`` +- ``WebInspectorModelContext`` +- ``WebInspectorModelContext/Configuration`` +- ``WebInspectorModelContext/Domain`` +- ``WebInspectorModelContext/State`` +- ``WebInspectorModelContext/Failure`` ### Reading DOM State @@ -83,8 +111,16 @@ access with no model layer. - ``NetworkRequest`` - ``ConsoleMessage`` -- ``WebInspectorFetchRequest`` -- ``WebInspectorFetchedResultsController`` +- ``NetworkQuery`` +- ``NetworkSort`` +- ``NetworkSection`` +- ``ConsoleQuery`` +- ``ConsoleSort`` +- ``ConsoleSection`` +- ``WebInspectorFetchedResults`` +- ``WebInspectorFetchedResultsSnapshot`` +- ``WebInspectorFetchedResultsTransaction`` +- ``WebInspectorFetchedResultsUpdate`` ### Runtime and CSS Models @@ -95,12 +131,10 @@ access with no model layer. - ``CSSStyleSection`` - ``CSSStyleProperty`` -### Domain Operations +### Mutations -- ``DOMModelController`` -- ``NetworkModelController`` -- ``ConsoleModelController`` -- ``RuntimeModelController`` -- ``CSSModelController`` -- ``PageModelController`` -- ``WebInspectorEditHistory`` +- ``WebInspectorUndoPolicy`` +- ``DOMRevealPolicy`` +- ``DOMMutationOutcome`` +- ``DOMMutationFailure`` +- ``DOMUndoCapability`` diff --git a/Sources/WebInspectorDataKit/WebInspectorDataKitLog.swift b/Sources/WebInspectorDataKit/WebInspectorDataKitLog.swift index 516e0441..61937773 100644 --- a/Sources/WebInspectorDataKit/WebInspectorDataKitLog.swift +++ b/Sources/WebInspectorDataKit/WebInspectorDataKitLog.swift @@ -10,17 +10,27 @@ enum WebInspectorDataKitLog { static func debug(_ message: String) { logger.debug("\(message, privacy: .public)") } + + static func error(_ message: String) { + logger.error("\(message, privacy: .public)") + } } -extension WebInspectorContext.State { +extension WebInspectorModelContext.State { var logDescription: String { switch self { case .attaching: return "attaching" + case let .synchronizing(generation): + return "synchronizing(\(generation))" case .attached: return "attached" case .detached: return "detached" + case .detaching: + return "detaching" + case .closed: + return "closed" case let .failed(error): return "failed(\(String(describing: error)))" } diff --git a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift b/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift deleted file mode 100644 index 7421fc79..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorDomainControllers.swift +++ /dev/null @@ -1,391 +0,0 @@ -import Foundation -import WebInspectorProxyKit - -/// Shared options for DataKit model mutations. -public struct WebInspectorMutationOptions: Sendable, Hashable { - /// Default mutation behavior: participate in WebKit undo history and fail - /// on stale model references. - public static let automatic = WebInspectorMutationOptions( - undo: .automatic, - staleModel: .fail - ) - - /// The undo policy for the mutation. - public var undo: WebInspectorUndoPolicy - - /// The stale-model handling policy for the mutation. - public var staleModel: WebInspectorStaleModelPolicy - - /// Creates mutation options. - public init( - undo: WebInspectorUndoPolicy = .automatic, - staleModel: WebInspectorStaleModelPolicy = .fail - ) { - self.undo = undo - self.staleModel = staleModel - } -} - -/// Controls whether a mutation participates in WebKit inspector undo history. -public enum WebInspectorUndoPolicy: Sendable, Hashable { - /// Let DataKit record undoable WebKit DOM mutations where supported. - case automatic - - /// Do not record an undo checkpoint for the mutation. - case disabled -} - -/// Controls how DataKit handles stale model references. -public enum WebInspectorStaleModelPolicy: Sendable, Hashable { - /// Fail when a model no longer belongs to the current context state. - case fail -} - -/// Controls how DOM selection changes should be revealed to UI tree views. -public enum DOMRevealPolicy: Sendable, Hashable { - /// Do not reveal or select the node. - case none - - /// Select the node without requesting scrolling. - case selectOnly - - /// Select and request scrolling the node into view. - case selectAndScroll -} - -/// The accepted subset of a requested DOM mutation. -public struct DOMMutationResult: Sendable, Hashable { - /// Node identities requested by the caller. - public var requestedNodeIDs: [DOMNode.ID] - - /// Node identities accepted by the backend mutation. - public var acceptedNodeIDs: [DOMNode.ID] - - /// Creates a DOM mutation result. - public init(requestedNodeIDs: [DOMNode.ID], acceptedNodeIDs: [DOMNode.ID]) { - self.requestedNodeIDs = requestedNodeIDs - self.acceptedNodeIDs = acceptedNodeIDs - } -} - -/// Domain operation surface for DOM model commands. -/// -/// Use this controller instead of dispatching ProxyKit DOM commands directly -/// when working with DataKit-owned DOM nodes. -public final class DOMModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Returns a live tree controller rooted at the current document. - public func treeController(isolation: isolated (any Actor) = #isolation) -> DOMTreeController { - context.rootTreeController(isolation: isolation) - } - - /// Requests regular child nodes for a DOM node. - public func requestChildren( - of nodeID: DOMNode.ID, - depth: Int = 1, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.requestChildren(for: nodeID, depth: depth, isolation: isolation) - } - - /// Selects a DOM node and optionally asks tree views to reveal it. - public func select( - _ nodeID: DOMNode.ID?, - reveal: DOMRevealPolicy = .selectAndScroll, - isolation: isolated (any Actor) = #isolation - ) throws { - try context.selectNode(nodeID, reveal: reveal, isolation: isolation) - } - - /// Sets an attribute value on a DOM node. - public func setAttribute( - _ name: String, - value: String, - on nodeID: DOMNode.ID, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setDOMAttribute(name, value: value, on: nodeID, options: options, isolation: isolation) - } - - /// Replaces a DOM node with the supplied outer HTML. - public func setOuterHTML( - _ html: String, - of nodeID: DOMNode.ID, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setDOMOuterHTML(html, of: nodeID, options: options, isolation: isolation) - } - - /// Removes DOM nodes from the inspected document. - public func remove( - _ nodeIDs: [DOMNode.ID], - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws -> DOMMutationResult { - try await context.removeDOMNodes(nodeIDs, options: options, isolation: isolation) - } - - /// Highlights a DOM node in the inspected page. - public func highlight( - _ nodeID: DOMNode.ID, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.highlightNode(for: nodeID, isolation: isolation) - } - - /// Clears the current DOM highlight. - public func hideHighlight(isolation: isolated (any Actor) = #isolation) async throws { - try await context.hideHighlight(isolation: isolation) - } - - /// Enables or disables WebKit's element picker. - public func setInspectMode( - enabled: Bool, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setElementPickerEnabled(enabled, isolation: isolation) - } -} - -/// Domain operation surface for CSS model commands. -public final class CSSModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Returns observable CSS styles for a DOM node. - public func styles( - for nodeID: DOMNode.ID, - isolation: isolated (any Actor) = #isolation - ) throws -> CSSStyles { - try context.styles(for: nodeID, isolation: isolation) - } - - /// Requests a CSS style refresh for a DOM node. - public func refreshStyles( - for nodeID: DOMNode.ID, - isolation: isolated (any Actor) = #isolation - ) throws { - _ = try context.refreshStyles(for: nodeID, isolation: isolation) - } - - /// Controls whether DataKit should hydrate CSS styles automatically. - public func setStyleHydrationActive( - _ active: Bool, - isolation: isolated (any Actor) = #isolation - ) { - context.setStyleHydrationActive(active, isolation: isolation) - } - - /// Enables or disables a CSS declaration. - public func setProperty( - _ propertyID: CSSStyleProperty.ID, - enabled: Bool, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setCSSProperty(propertyID, enabled: enabled, options: options, isolation: isolation) - } - - /// Starts an asynchronous CSS declaration toggle request. - @discardableResult - public func requestSetProperty( - _ propertyID: CSSStyleProperty.ID, - enabled: Bool, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) -> Bool { - context.requestSetCSSProperty(propertyID, enabled: enabled, options: options, isolation: isolation) - } - - /// Replaces the declaration text for a CSS property. - public func setDeclarationText( - _ text: String, - for propertyID: CSSStyleProperty.ID, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setCSSDeclarationText(text, for: propertyID, options: options, isolation: isolation) - } - - /// Replaces a CSS rule selector. - public func setRuleSelector( - _ selector: String, - for ruleID: CSSStyleRule.ID, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setCSSRuleSelector(selector, for: ruleID, options: options, isolation: isolation) - } - - /// Replaces the full text of a stylesheet. - public func setStyleSheetText( - _ text: String, - for styleSheetID: CSS.StyleSheet.ID, - options: WebInspectorMutationOptions = .automatic, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.setCSSStyleSheetText(text, for: styleSheetID, options: options, isolation: isolation) - } -} - -/// Domain operation surface for page-level commands. -public final class PageModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Reloads the inspected page. - public func reload( - ignoringCache: Bool = false, - isolation: isolated (any Actor) = #isolation - ) async throws { - try await context.reloadPage(ignoringCache: ignoringCache, isolation: isolation) - } -} - -/// Domain operation surface for Runtime model commands. -public final class RuntimeModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Evaluates JavaScript in the selected or supplied Runtime context. - public func evaluate( - _ expression: String, - in runtimeContext: RuntimeContext? = nil, - isolation: isolated (any Actor) = #isolation - ) async throws -> RuntimeEvaluation { - try await context.evaluate(expression, in: runtimeContext, isolation: isolation) - } -} - -/// Domain operation surface for Network model queries and commands. -public final class NetworkModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Creates observable network request results. - public func fetchedResults( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - context.fetchedResults(for: descriptor, sectionBy: sectionBy, isolation: isolation) - } - - /// Creates a controller for observable network request results. - public func fetchedResultsController( - for descriptor: WebInspectorFetchDescriptor = .init(), - sectionBy: WebInspectorSectionDescriptor? = nil, - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, sectionBy: sectionBy, isolation: isolation) - ) - } - - /// Clears recorded network requests. - public func clearRequests(isolation: isolated (any Actor) = #isolation) { - context.clearNetworkRequests(isolation: isolation) - } -} - -/// Domain operation surface for Console model queries and commands. -public final class ConsoleModelController { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Creates observable console message results. - public func fetchedResults( - for descriptor: WebInspectorFetchDescriptor = .init(), - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResults { - context.fetchedResults(for: descriptor, isolation: isolation) - } - - /// Creates a controller for observable console message results. - public func fetchedResultsController( - for descriptor: WebInspectorFetchDescriptor = .init(), - isolation: isolated (any Actor) = #isolation - ) -> WebInspectorFetchedResultsController { - WebInspectorFetchedResultsController( - fetchedResults: fetchedResults(for: descriptor, isolation: isolation) - ) - } -} - -/// Undo/redo surface for edits recorded through DataKit model operations. -public final class WebInspectorEditHistory { - private let context: WebInspectorContext - - package init(context: WebInspectorContext) { - self.context = context - } - - /// Undoes the most recent edit recorded through DataKit. - public func undo(isolation: isolated (any Actor) = #isolation) async throws { - try await context.undoDOMChange(isolation: isolation) - } - - /// Redoes the most recent edit recorded through DataKit. - public func redo(isolation: isolated (any Actor) = #isolation) async throws { - try await context.redoDOMChange(isolation: isolation) - } -} - -public extension WebInspectorContext { - /// DOM model operations for this context. - var dom: DOMModelController { - DOMModelController(context: self) - } - - /// CSS model operations for this context. - var css: CSSModelController { - CSSModelController(context: self) - } - - /// Network model operations and fetch surfaces for this context. - var network: NetworkModelController { - NetworkModelController(context: self) - } - - /// Runtime model operations for this context. - var runtime: RuntimeModelController { - RuntimeModelController(context: self) - } - - /// Console model operations and fetch surfaces for this context. - var console: ConsoleModelController { - ConsoleModelController(context: self) - } - - /// Page-level operations for this context. - var page: PageModelController { - PageModelController(context: self) - } - - /// Undo and redo operations for edits recorded by this context. - var editHistory: WebInspectorEditHistory { - WebInspectorEditHistory(context: self) - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorDomainEnablement.swift b/Sources/WebInspectorDataKit/WebInspectorDomainEnablement.swift deleted file mode 100644 index 14263f4b..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorDomainEnablement.swift +++ /dev/null @@ -1,366 +0,0 @@ -import Foundation -import WebInspectorProxyKit - -enum WebInspectorEnabledDomain: Hashable, Sendable { - case inspector - case console - case network - case runtime - - var rawValue: String { - switch self { - case .inspector: - return "Inspector" - case .console: - return "Console" - case .network: - return "Network" - case .runtime: - return "Runtime" - } - } - - fileprivate func enable(on target: WebInspectorTarget) async throws { - switch self { - case .inspector: - try await target.inspector.enable() - try await target.inspector.initialized() - case .console: - try await target.console.enable() - case .network: - try await target.network.enable() - case .runtime: - try await target.runtime.enable() - } - } - - fileprivate func disable(on target: WebInspectorTarget) async throws { - switch self { - case .inspector: - try await target.inspector.disable() - case .console: - try await target.console.disable() - case .network: - try await target.network.disable() - case .runtime: - try await target.runtime.disable() - } - } - - fileprivate func commandFailed(method: String, error: any Error) -> WebInspectorProxyError { - .commandFailed( - domain: rawValue, - method: method, - message: String(describing: error) - ) - } -} - -private struct WebInspectorDomainEnablementKey: Hashable, Sendable { - var targetID: WebInspectorTarget.ID - var pageBinding: String? - var domain: WebInspectorEnabledDomain - - init(target: WebInspectorTarget, domain: WebInspectorEnabledDomain) { - targetID = target.id - pageBinding = target.pageBindingID - self.domain = domain - } -} - -#if DEBUG -private struct AcquireWaitingForDisableWaiterForTesting: Sendable { - var minimumSequence: UInt64 - var continuation: CheckedContinuation -} -#endif - -actor WebInspectorDomainEnablementRegistry { - private enum Entry: Sendable { - case enabling(count: Int, generation: Int, task: Task) - case enabled(count: Int) - case disabling(generation: Int, task: Task) - } - - private var entries: [WebInspectorDomainEnablementKey: Entry] - private var nextGeneration: Int -#if DEBUG - private var acquireWaitingForDisableSequenceForTestingStorage: UInt64 - private var acquireWaitingForDisableWaitersForTesting: [AcquireWaitingForDisableWaiterForTesting] -#endif - - init() { - entries = [:] - nextGeneration = 0 -#if DEBUG - acquireWaitingForDisableSequenceForTestingStorage = 0 - acquireWaitingForDisableWaitersForTesting = [] -#endif - } - -#if DEBUG - var acquireWaitingForDisableSequenceForTesting: UInt64 { - acquireWaitingForDisableSequenceForTestingStorage - } - - func waitForAcquireWaitingForDisableForTesting( - after baselineSequence: UInt64, - count: UInt64 = 1 - ) async { - let minimumSequence = baselineSequence + count - guard acquireWaitingForDisableSequenceForTestingStorage < minimumSequence else { - return - } - - await withCheckedContinuation { continuation in - if acquireWaitingForDisableSequenceForTestingStorage >= minimumSequence { - continuation.resume() - } else { - acquireWaitingForDisableWaitersForTesting.append(AcquireWaitingForDisableWaiterForTesting( - minimumSequence: minimumSequence, - continuation: continuation - )) - } - } - } -#endif - - func acquire(_ domain: WebInspectorEnabledDomain, on target: WebInspectorTarget) async throws { - let key = WebInspectorDomainEnablementKey(target: target, domain: domain) - - switch entries[key] { - case let .enabled(count): - entries[key] = .enabled(count: count + 1) - WebInspectorDataKitLog.debug( - "domain acquire shared domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count + 1)" - ) - return - case let .enabling(count, generation, task): - entries[key] = .enabling(count: count + 1, generation: generation, task: task) - WebInspectorDataKitLog.debug( - "domain acquire pending domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count + 1)" - ) - try await finishEnabling(key: key, domain: domain, generation: generation, task: task) - case let .disabling(generation, task): - WebInspectorDataKitLog.debug( - "domain acquire waits for disable domain=\(domain.rawValue) target=\(target.id.rawValue)" - ) - recordAcquireWaitingForDisableForTesting() - if let error = await finishDisabling(key: key, domain: domain, generation: generation, task: task) { - throw error - } - try await acquire(domain, on: target) - case nil: - let generation = nextGeneration - nextGeneration += 1 - let task = Task { - try await domain.enable(on: target) - } - entries[key] = .enabling(count: 1, generation: generation, task: task) - WebInspectorDataKitLog.debug("domain enable start domain=\(domain.rawValue) target=\(target.id.rawValue)") - try await finishEnabling(key: key, domain: domain, generation: generation, task: task) - } - } - - func release(_ domain: WebInspectorEnabledDomain, on target: WebInspectorTarget) async -> WebInspectorProxyError? { - let key = WebInspectorDomainEnablementKey(target: target, domain: domain) - - switch entries[key] { - case let .enabled(count): - precondition(count > 0, "WebInspector domain enablement count must be positive.") - if count > 1 { - entries[key] = .enabled(count: count - 1) - WebInspectorDataKitLog.debug( - "domain release shared domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count - 1)" - ) - return nil - } - let generation = nextGeneration - nextGeneration += 1 - let task = Task { - await Self.disable(domain, on: target) - } - entries[key] = .disabling(generation: generation, task: task) - WebInspectorDataKitLog.debug("domain disable start domain=\(domain.rawValue) target=\(target.id.rawValue)") - return await finishDisabling(key: key, domain: domain, generation: generation, task: task) - case let .enabling(count, generation, task): - precondition(count > 0, "WebInspector domain enablement count must be positive.") - if count > 1 { - entries[key] = .enabling(count: count - 1, generation: generation, task: task) - WebInspectorDataKitLog.debug( - "domain release pending domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count - 1)" - ) - } else { - let disableGeneration = nextGeneration - nextGeneration += 1 - let disableTask = Task { - await Self.disableAfterPendingEnable(domain, on: target, task: task) - } - entries[key] = .disabling(generation: disableGeneration, task: disableTask) - WebInspectorDataKitLog.debug("domain release pending cancelled domain=\(domain.rawValue) target=\(target.id.rawValue)") - return await finishDisabling( - key: key, - domain: domain, - generation: disableGeneration, - task: disableTask - ) - } - return nil - case .disabling: - preconditionFailure("Releasing WebInspector domain enablement while disable is already in flight.") - case nil: - preconditionFailure("Releasing WebInspector domain enablement without a matching acquire.") - } - } - - func discardLease(_ domain: WebInspectorEnabledDomain, on target: WebInspectorTarget) { - let key = WebInspectorDomainEnablementKey(target: target, domain: domain) - - switch entries[key] { - case let .enabled(count): - precondition(count > 0, "WebInspector domain enablement count must be positive.") - if count > 1 { - entries[key] = .enabled(count: count - 1) - WebInspectorDataKitLog.debug( - "domain lease discarded shared domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count - 1)" - ) - } else { - entries[key] = nil - WebInspectorDataKitLog.debug( - "domain lease discarded domain=\(domain.rawValue) target=\(target.id.rawValue)" - ) - } - case let .enabling(count, generation, task): - precondition(count > 0, "WebInspector domain enablement count must be positive.") - if count > 1 { - entries[key] = .enabling(count: count - 1, generation: generation, task: task) - WebInspectorDataKitLog.debug( - "domain lease discarded pending domain=\(domain.rawValue) target=\(target.id.rawValue) count=\(count - 1)" - ) - } else { - entries[key] = nil - WebInspectorDataKitLog.debug( - "domain lease discarded pending domain=\(domain.rawValue) target=\(target.id.rawValue)" - ) - } - case .disabling: - preconditionFailure("Discarding WebInspector domain enablement while disable is already in flight.") - case nil: - preconditionFailure("Discarding WebInspector domain enablement without a matching acquire.") - } - } - - private func recordAcquireWaitingForDisableForTesting() { -#if DEBUG - acquireWaitingForDisableSequenceForTestingStorage += 1 - var unresolved: [AcquireWaitingForDisableWaiterForTesting] = [] - for waiter in acquireWaitingForDisableWaitersForTesting { - if acquireWaitingForDisableSequenceForTestingStorage >= waiter.minimumSequence { - waiter.continuation.resume() - } else { - unresolved.append(waiter) - } - } - acquireWaitingForDisableWaitersForTesting = unresolved -#endif - } - - private func finishEnabling( - key: WebInspectorDomainEnablementKey, - domain: WebInspectorEnabledDomain, - generation: Int, - task: Task - ) async throws { - do { - try await task.value - if case let .enabling(count, currentGeneration, _) = entries[key], - currentGeneration == generation { - entries[key] = .enabled(count: count) - WebInspectorDataKitLog.debug( - "domain enable finished domain=\(domain.rawValue) target=\(key.targetID.rawValue) count=\(count)" - ) - } - } catch { - if case let .enabling(_, currentGeneration, _) = entries[key], - currentGeneration == generation { - entries[key] = nil - } - WebInspectorDataKitLog.debug( - "domain enable failed domain=\(domain.rawValue) target=\(key.targetID.rawValue) error=\(String(describing: error))" - ) - if let error = error as? WebInspectorProxyError { - throw error - } - throw domain.commandFailed(method: "enable", error: error) - } - } - - private func finishDisabling( - key: WebInspectorDomainEnablementKey, - domain: WebInspectorEnabledDomain, - generation: Int, - task: Task - ) async -> WebInspectorProxyError? { - let error = await task.value - if case let .disabling(currentGeneration, _) = entries[key], - currentGeneration == generation { - entries[key] = nil - WebInspectorDataKitLog.debug( - "domain disable registry cleared domain=\(domain.rawValue) target=\(key.targetID.rawValue)" - ) - } - return error - } - - private nonisolated static func disable( - _ domain: WebInspectorEnabledDomain, - on target: WebInspectorTarget - ) async -> WebInspectorProxyError? { - if let binding = target.pageBindingID { - let currentBinding = await target.proxy.currentPageBindingID - guard currentBinding == binding else { - WebInspectorDataKitLog.debug( - "domain disable skipped stale page binding domain=\(domain.rawValue) target=\(target.id.rawValue)" - ) - return nil - } - } - do { - try await domain.disable(on: target) - WebInspectorDataKitLog.debug("domain disable finished domain=\(domain.rawValue) target=\(target.id.rawValue)") - return nil - } catch WebInspectorProxyError.closed { - WebInspectorDataKitLog.debug("domain disable skipped closed domain=\(domain.rawValue) target=\(target.id.rawValue)") - return nil - } catch WebInspectorProxyError.disconnected(_) { - WebInspectorDataKitLog.debug("domain disable skipped disconnected domain=\(domain.rawValue) target=\(target.id.rawValue)") - return nil - } catch let error as WebInspectorProxyError { - WebInspectorDataKitLog.debug( - "domain disable failed domain=\(domain.rawValue) target=\(target.id.rawValue) error=\(String(describing: error))" - ) - return error - } catch { - WebInspectorDataKitLog.debug( - "domain disable failed domain=\(domain.rawValue) target=\(target.id.rawValue) error=\(String(describing: error))" - ) - return domain.commandFailed(method: "disable", error: error) - } - } - - private nonisolated static func disableAfterPendingEnable( - _ domain: WebInspectorEnabledDomain, - on target: WebInspectorTarget, - task: Task - ) async -> WebInspectorProxyError? { - do { - try await task.value - } catch { - WebInspectorDataKitLog.debug( - "domain release pending skipped disable domain=\(domain.rawValue) target=\(target.id.rawValue) error=\(String(describing: error))" - ) - return nil - } - return await disable(domain, on: target) - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorEventPump.swift b/Sources/WebInspectorDataKit/WebInspectorEventPump.swift deleted file mode 100644 index 28465506..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorEventPump.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -struct WebInspectorEventPump: Sendable { - private let task: Task - - init( - stream: Events, - isolation: isolated (any Actor), - apply: @escaping (Event) async -> Void - ) where Events.Element == Event, Events.Failure == Never { - let target = WebInspectorEventPumpTarget(apply: apply) - task = Task.detached(priority: .userInitiated) { - for await event in stream { - if Task.isCancelled { - break - } - await target.apply(event, isolation: isolation) - } - } - } - - func stop() { - task.cancel() - } -} - -// The detached task may carry this target across executors, but it never invokes -// the non-Sendable apply closure directly; apply(_:isolation:) runs on the -// WebInspectorContext owner actor passed to the pump initializer. -private final class WebInspectorEventPumpTarget: @unchecked Sendable { - private let applyEvent: (Event) async -> Void - - init(apply: @escaping (Event) async -> Void) { - applyEvent = apply - } - - func apply(_ event: Event, isolation: isolated (any Actor)) async { - _ = isolation - await applyEvent(event) - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift deleted file mode 100644 index cd5621d6..00000000 --- a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsController.swift +++ /dev/null @@ -1,378 +0,0 @@ -import Foundation - -/// Section/item position inside fetched results. -public struct WebInspectorFetchedResultsIndexPath: Hashable, Sendable { - /// The section index. - public var section: Int - - /// The item index within the section. - public var item: Int - - /// Creates an index path. - public init(section: Int, item: Int) { - self.section = section - self.item = item - } -} - -/// Immutable snapshot of fetched-result section and item identities. -public struct WebInspectorFetchedResultsSnapshot: Hashable, Sendable { - /// One section in a fetched-results snapshot. - public struct Section: Identifiable, Hashable, Sendable { - /// The stable section identity. - public let id: WebInspectorFetchSectionID - - /// The display title for the section. - public let title: String? - - /// Item identities in section order. - public let itemIDs: [ItemID] - - /// Creates a snapshot section. - public init(id: WebInspectorFetchSectionID, title: String?, itemIDs: [ItemID]) { - self.id = id - self.title = title - self.itemIDs = itemIDs - } - } - - /// Sections in display order. - public let sections: [Section] - - /// Creates a fetched-results snapshot. - public init(sections: [Section] = []) { - self.sections = sections - let itemIDs = sections.flatMap(\.itemIDs) - precondition( - Set(itemIDs).count == itemIDs.count, - "WebInspectorFetchedResultsSnapshot item IDs must be unique." - ) - } - - /// Creates a single-section snapshot from item identities. - public init(itemIDs: [ItemID]) { - self.init(sections: [ - Section(id: .defaultSection, title: nil, itemIDs: itemIDs) - ]) - } - - /// Section identities in display order. - public var sectionIDs: [WebInspectorFetchSectionID] { - sections.map(\.id) - } - - /// All item identities in display order. - public var itemIDs: [ItemID] { - sections.flatMap(\.itemIDs) - } - - /// Returns item identities for a section. - public func itemIDs(in sectionID: WebInspectorFetchSectionID) -> [ItemID]? { - sections.first { $0.id == sectionID }?.itemIDs - } -} - -extension WebInspectorFetchedResultsSnapshot { - init( - sections: [WebInspectorFetchSection] - ) where Model.ID == ItemID { - self.init(sections: sections.map { section in - Section( - id: section.id, - title: section.title, - itemIDs: section.items.map(\.id) - ) - }) - } -} - -/// Section-level change in a fetched-results transaction. -public enum WebInspectorFetchedResultsSectionChange: Hashable, Sendable { - /// A section was inserted. - case insert(sectionID: WebInspectorFetchSectionID, index: Int) - - /// A section was deleted. - case delete(sectionID: WebInspectorFetchSectionID, index: Int) - - /// A section moved. - case move(sectionID: WebInspectorFetchSectionID, from: Int, to: Int) - - /// A section's display metadata changed. - case update(sectionID: WebInspectorFetchSectionID, index: Int) -} - -/// Item-level change in a fetched-results transaction. -public enum WebInspectorFetchedResultsItemChange: Hashable, Sendable { - /// An item was inserted. - case insert(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) - - /// An item was deleted. - case delete(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) - - /// An item moved. - case move( - itemID: ItemID, - from: WebInspectorFetchedResultsIndexPath, - to: WebInspectorFetchedResultsIndexPath - ) - /// An item's model changed without moving. - case update(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) -} - -/// A batch of fetched-results changes between two snapshots. -public struct WebInspectorFetchedResultsTransaction: Hashable, Sendable { - /// Item identity type for the model. - public typealias ItemID = Model.ID - - /// Snapshot before the transaction. - public let oldSnapshot: WebInspectorFetchedResultsSnapshot - - /// Snapshot after the transaction. - public let newSnapshot: WebInspectorFetchedResultsSnapshot - - /// A Boolean value indicating whether consumers should treat the change as a full reset. - public let isReset: Bool - - /// Section changes in application order. - public let sectionChanges: [WebInspectorFetchedResultsSectionChange] - - /// Item changes in application order. - public let itemChanges: [WebInspectorFetchedResultsItemChange] - - /// A Boolean value indicating whether the transaction contains any changes. - public var hasChanges: Bool { - isReset || sectionChanges.isEmpty == false || itemChanges.isEmpty == false - } - - /// Creates a fetched-results transaction. - public init( - oldSnapshot: WebInspectorFetchedResultsSnapshot, - newSnapshot: WebInspectorFetchedResultsSnapshot, - isReset: Bool = false, - sectionChanges: [WebInspectorFetchedResultsSectionChange] = [], - itemChanges: [WebInspectorFetchedResultsItemChange] - ) { - self.oldSnapshot = oldSnapshot - self.newSnapshot = newSnapshot - self.isReset = isReset - self.sectionChanges = sectionChanges - self.itemChanges = itemChanges - } - - init( - oldSnapshot: WebInspectorFetchedResultsSnapshot, - newSnapshot: WebInspectorFetchedResultsSnapshot, - updatedItemIDs: Set = [] - ) { - self.init( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot, - isReset: false, - sectionChanges: Self.sectionChanges(from: oldSnapshot, to: newSnapshot), - itemChanges: Self.itemChanges(from: oldSnapshot, to: newSnapshot, updatedItemIDs: updatedItemIDs) - ) - } - - private static func sectionChanges( - from oldSnapshot: WebInspectorFetchedResultsSnapshot, - to newSnapshot: WebInspectorFetchedResultsSnapshot - ) -> [WebInspectorFetchedResultsSectionChange] { - let oldIndexes = indexSections(oldSnapshot.sections) - let newIndexes = indexSections(newSnapshot.sections) - - let deletes = oldSnapshot.sections.enumerated() - .filter { _, section in newIndexes[section.id] == nil } - .sorted { lhs, rhs in lhs.offset > rhs.offset } - .map { index, section in - WebInspectorFetchedResultsSectionChange.delete(sectionID: section.id, index: index) - } - - let inserts = newSnapshot.sections.enumerated() - .filter { _, section in oldIndexes[section.id] == nil } - .map { index, section in - WebInspectorFetchedResultsSectionChange.insert(sectionID: section.id, index: index) - } - - let moves = newSnapshot.sections.enumerated() - .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in - guard let oldIndex = oldIndexes[section.id], oldIndex != newIndex else { - return nil - } - return .move(sectionID: section.id, from: oldIndex, to: newIndex) - } - - let updates = newSnapshot.sections.enumerated() - .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in - guard let oldIndex = oldIndexes[section.id] else { - return nil - } - guard oldSnapshot.sections[oldIndex].title != section.title else { - return nil - } - return .update(sectionID: section.id, index: newIndex) - } - - return deletes + inserts + moves + updates - } - - private static func indexSections( - _ sections: [WebInspectorFetchedResultsSnapshot.Section] - ) -> [WebInspectorFetchSectionID: Int] { - Dictionary( - uniqueKeysWithValues: sections.enumerated().map { index, section in - (section.id, index) - } - ) - } - - private static func itemChanges( - from oldSnapshot: WebInspectorFetchedResultsSnapshot, - to newSnapshot: WebInspectorFetchedResultsSnapshot, - updatedItemIDs: Set - ) -> [WebInspectorFetchedResultsItemChange] { - let oldPositions = indexItems(oldSnapshot) - let newPositions = indexItems(newSnapshot) - - let deletes = oldPositions.values - .filter { newPositions[$0.itemID] == nil } - .sorted { lhs, rhs in lhs.indexPath > rhs.indexPath } - .map { - WebInspectorFetchedResultsItemChange.delete( - itemID: $0.itemID, - indexPath: $0.indexPath - ) - } - - let inserts = newPositions.values - .filter { oldPositions[$0.itemID] == nil } - .sorted { lhs, rhs in lhs.indexPath < rhs.indexPath } - .map { - WebInspectorFetchedResultsItemChange.insert( - itemID: $0.itemID, - indexPath: $0.indexPath - ) - } - - let moves = newPositions.values - .compactMap { newPosition -> WebInspectorFetchedResultsItemChange? in - guard let oldPosition = oldPositions[newPosition.itemID], - oldPosition.indexPath != newPosition.indexPath else { - return nil - } - return .move( - itemID: newPosition.itemID, - from: oldPosition.indexPath, - to: newPosition.indexPath - ) - } - .sorted { lhs, rhs in - lhs.newIndexPathForOrdering < rhs.newIndexPathForOrdering - } - - let updates = newPositions.values - .compactMap { newPosition -> WebInspectorFetchedResultsItemChange? in - guard let oldPosition = oldPositions[newPosition.itemID], - oldPosition.sectionID == newPosition.sectionID, - oldPosition.indexPath == newPosition.indexPath, - updatedItemIDs.contains(newPosition.itemID) else { - return nil - } - return .update(itemID: newPosition.itemID, indexPath: newPosition.indexPath) - } - .sorted { lhs, rhs in - lhs.newIndexPathForOrdering < rhs.newIndexPathForOrdering - } - - return deletes + inserts + moves + updates - } - - private struct ItemPosition { - var itemID: ItemID - var sectionID: WebInspectorFetchSectionID - var indexPath: WebInspectorFetchedResultsIndexPath - } - - private static func indexItems( - _ snapshot: WebInspectorFetchedResultsSnapshot - ) -> [ItemID: ItemPosition] { - var positions: [ItemID: ItemPosition] = [:] - for (sectionIndex, section) in snapshot.sections.enumerated() { - for (itemIndex, itemID) in section.itemIDs.enumerated() where positions[itemID] == nil { - positions[itemID] = ItemPosition( - itemID: itemID, - sectionID: section.id, - indexPath: WebInspectorFetchedResultsIndexPath( - section: sectionIndex, - item: itemIndex - ) - ) - } - } - return positions - } -} - -extension WebInspectorFetchedResultsItemChange { - fileprivate var newIndexPathForOrdering: WebInspectorFetchedResultsIndexPath { - switch self { - case .insert(_, let indexPath), - .update(_, let indexPath), - .delete(_, let indexPath): - return indexPath - case .move(_, _, let indexPath): - return indexPath - } - } -} - -extension WebInspectorFetchedResultsIndexPath: Comparable { - /// Orders index paths by section and then item. - public static func < ( - lhs: WebInspectorFetchedResultsIndexPath, - rhs: WebInspectorFetchedResultsIndexPath - ) -> Bool { - if lhs.section != rhs.section { - return lhs.section < rhs.section - } - return lhs.item < rhs.item - } -} - -/// Controller wrapper around ``WebInspectorFetchedResults``. -public final class WebInspectorFetchedResultsController { - /// The observable fetched-results model. - public let fetchedResults: WebInspectorFetchedResults - - /// The descriptor currently used by the results. - public var fetchDescriptor: WebInspectorFetchDescriptor { - fetchedResults.fetchDescriptor - } - - /// The fetched models in display order. - public var items: [Model] { - fetchedResults.items - } - - /// The current fetched-results snapshot. - public var snapshot: WebInspectorFetchedResultsSnapshot { - WebInspectorFetchedResultsSnapshot(sections: fetchedResults.sections) - } - - /// Stream of transactions emitted after result changes. - public var transactions: AsyncStream> { - fetchedResults.makeTransactionStream() - } - - /// Creates a controller for fetched results. - public init(fetchedResults: WebInspectorFetchedResults) { - self.fetchedResults = fetchedResults - } - - /// Replaces the fetch descriptor and updates the result contents. - public func updateFetchDescriptor( - _ descriptor: WebInspectorFetchDescriptor, - isolation: isolated (any Actor) = #isolation - ) { - fetchedResults.updateFetchDescriptor(descriptor, isolation: isolation) - } -} diff --git a/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift new file mode 100644 index 00000000..5c2bce9c --- /dev/null +++ b/Sources/WebInspectorDataKit/WebInspectorFetchedResultsPublication.swift @@ -0,0 +1,667 @@ +import Foundation +import Synchronization + +/// Section/item position inside fetched results. +public struct WebInspectorFetchedResultsIndexPath: Hashable, Sendable { + /// The section index. + public var section: Int + + /// The item index within the section. + public var item: Int + + /// Creates an index path. + public init(section: Int, item: Int) { + self.section = section + self.item = item + } +} + +/// Immutable snapshot of fetched-result section and item identities. +public struct WebInspectorFetchedResultsSnapshot: Hashable, Sendable { + /// One section in a fetched-results snapshot. + public struct Section: Identifiable, Hashable, Sendable { + /// The stable section identity. + public let id: WebInspectorFetchSectionID + + /// The display title for the section. + public let title: String? + + /// Item identities in section order. + public let itemIDs: [ItemID] + + /// Creates a snapshot section. + public init(id: WebInspectorFetchSectionID, title: String?, itemIDs: [ItemID]) { + self.id = id + self.title = title + self.itemIDs = itemIDs + } + } + + /// Sections in display order. + public let sections: [Section] + + /// Creates a fetched-results snapshot. + public init(sections: [Section] = []) { + self.sections = sections + let itemIDs = sections.flatMap(\.itemIDs) + precondition( + Set(itemIDs).count == itemIDs.count, + "WebInspectorFetchedResultsSnapshot item IDs must be unique." + ) + } + + /// Creates a single-section snapshot from item identities. + public init(itemIDs: [ItemID]) { + self.init(sections: [ + Section(id: .defaultSection, title: nil, itemIDs: itemIDs) + ]) + } + + /// Section identities in display order. + public var sectionIDs: [WebInspectorFetchSectionID] { + sections.map(\.id) + } + + /// All item identities in display order. + public var itemIDs: [ItemID] { + sections.flatMap(\.itemIDs) + } + + /// Returns item identities for a section. + public func itemIDs(in sectionID: WebInspectorFetchSectionID) -> [ItemID]? { + sections.first { $0.id == sectionID }?.itemIDs + } +} + +extension WebInspectorFetchedResultsSnapshot { + init( + sections: [WebInspectorFetchSection] + ) where Model.ID == ItemID, Model.ID: Hashable & Sendable { + self.init(sections: sections.map { section in + Section( + id: section.id, + title: section.title, + itemIDs: section.items.map(\.id) + ) + }) + } +} + +/// Section-level change in a fetched-results transaction. +public enum WebInspectorFetchedResultsSectionChange: Hashable, Sendable { + /// A section was inserted. + case insert(sectionID: WebInspectorFetchSectionID, index: Int) + + /// A section was deleted. + case delete(sectionID: WebInspectorFetchSectionID, index: Int) + + /// A section moved. + case move(sectionID: WebInspectorFetchSectionID, from: Int, to: Int) + + /// A section's display metadata changed. + case update(sectionID: WebInspectorFetchSectionID, index: Int) +} + +/// Item-level change in a fetched-results transaction. +public enum WebInspectorFetchedResultsItemChange: Hashable, Sendable { + /// An item was inserted. + case insert(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) + + /// An item was deleted. + case delete(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) + + /// An item moved. + case move( + itemID: ItemID, + from: WebInspectorFetchedResultsIndexPath, + to: WebInspectorFetchedResultsIndexPath + ) + /// An item's model changed without moving. + case update(itemID: ItemID, indexPath: WebInspectorFetchedResultsIndexPath) +} + +/// A batch of fetched-results changes between two snapshots. +public struct WebInspectorFetchedResultsTransaction: Hashable, Sendable { + /// Snapshot before the transaction. + public let oldSnapshot: WebInspectorFetchedResultsSnapshot + + /// Snapshot after the transaction. + public let newSnapshot: WebInspectorFetchedResultsSnapshot + + /// A Boolean value indicating whether consumers should treat the change as a full reset. + public let isReset: Bool + + /// Section changes in application order. + public let sectionChanges: [WebInspectorFetchedResultsSectionChange] + + /// Item changes in application order. + public let itemChanges: [WebInspectorFetchedResultsItemChange] + + /// A Boolean value indicating whether the transaction contains any changes. + public var hasChanges: Bool { + isReset || sectionChanges.isEmpty == false || itemChanges.isEmpty == false + } + + /// Creates a fetched-results transaction. + public init( + oldSnapshot: WebInspectorFetchedResultsSnapshot, + newSnapshot: WebInspectorFetchedResultsSnapshot, + isReset: Bool = false, + sectionChanges: [WebInspectorFetchedResultsSectionChange] = [], + itemChanges: [WebInspectorFetchedResultsItemChange] + ) { + self.oldSnapshot = oldSnapshot + self.newSnapshot = newSnapshot + self.isReset = isReset + self.sectionChanges = sectionChanges + self.itemChanges = itemChanges + } + + init( + oldSnapshot: WebInspectorFetchedResultsSnapshot, + newSnapshot: WebInspectorFetchedResultsSnapshot, + updatedItemIDs: Set = [] + ) { + self.init( + oldSnapshot: oldSnapshot, + newSnapshot: newSnapshot, + isReset: false, + sectionChanges: Self.sectionChanges(from: oldSnapshot, to: newSnapshot), + itemChanges: Self.itemChanges(from: oldSnapshot, to: newSnapshot, updatedItemIDs: updatedItemIDs) + ) + } + + private static func sectionChanges( + from oldSnapshot: WebInspectorFetchedResultsSnapshot, + to newSnapshot: WebInspectorFetchedResultsSnapshot + ) -> [WebInspectorFetchedResultsSectionChange] { + let oldIndexes = indexSections(oldSnapshot.sections) + let newIndexes = indexSections(newSnapshot.sections) + + let deletes = oldSnapshot.sections.enumerated() + .filter { _, section in newIndexes[section.id] == nil } + .sorted { lhs, rhs in lhs.offset > rhs.offset } + .map { index, section in + WebInspectorFetchedResultsSectionChange.delete(sectionID: section.id, index: index) + } + + let inserts = newSnapshot.sections.enumerated() + .filter { _, section in oldIndexes[section.id] == nil } + .map { index, section in + WebInspectorFetchedResultsSectionChange.insert(sectionID: section.id, index: index) + } + + let oldCommonOrder = oldSnapshot.sectionIDs.filter { newIndexes[$0] != nil } + let newCommonOrder = newSnapshot.sectionIDs.filter { oldIndexes[$0] != nil } + let moves: [WebInspectorFetchedResultsSectionChange] = if oldCommonOrder == newCommonOrder { + [] + } else { + newSnapshot.sections.enumerated() + .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in + guard let oldIndex = oldIndexes[section.id], oldIndex != newIndex else { + return nil + } + return .move(sectionID: section.id, from: oldIndex, to: newIndex) + } + } + + let updates = newSnapshot.sections.enumerated() + .compactMap { newIndex, section -> WebInspectorFetchedResultsSectionChange? in + guard let oldIndex = oldIndexes[section.id] else { + return nil + } + guard oldSnapshot.sections[oldIndex].title != section.title else { + return nil + } + return .update(sectionID: section.id, index: newIndex) + } + + return deletes + inserts + moves + updates + } + + private static func indexSections( + _ sections: [WebInspectorFetchedResultsSnapshot.Section] + ) -> [WebInspectorFetchSectionID: Int] { + Dictionary( + uniqueKeysWithValues: sections.enumerated().map { index, section in + (section.id, index) + } + ) + } + + private static func itemChanges( + from oldSnapshot: WebInspectorFetchedResultsSnapshot, + to newSnapshot: WebInspectorFetchedResultsSnapshot, + updatedItemIDs: Set + ) -> [WebInspectorFetchedResultsItemChange] { + let oldPositions = indexItems(oldSnapshot) + let newPositions = indexItems(newSnapshot) + + let deletes = oldPositions.values + .filter { newPositions[$0.itemID] == nil } + .sorted { lhs, rhs in lhs.indexPath > rhs.indexPath } + .map { + WebInspectorFetchedResultsItemChange.delete( + itemID: $0.itemID, + indexPath: $0.indexPath + ) + } + + let inserts = newPositions.values + .filter { oldPositions[$0.itemID] == nil } + .sorted { lhs, rhs in lhs.indexPath < rhs.indexPath } + .map { + WebInspectorFetchedResultsItemChange.insert( + itemID: $0.itemID, + indexPath: $0.indexPath + ) + } + + let sectionMembershipChanges = sectionMembershipChanges( + from: oldSnapshot, + to: newSnapshot, + oldPositions: oldPositions, + newPositions: newPositions + ) + + let moves = moveChanges( + from: oldSnapshot, + to: newSnapshot, + oldPositions: oldPositions, + newPositions: newPositions, + updatedItemIDs: updatedItemIDs, + excludedItemIDs: Set(sectionMembershipChanges.map(itemID)) + ) + + let updates = newSnapshot.itemIDs.compactMap { itemID -> WebInspectorFetchedResultsItemChange? in + guard updatedItemIDs.contains(itemID), + let oldPosition = oldPositions[itemID], + let newPosition = newPositions[itemID], + oldPosition.sectionID == newPosition.sectionID, + oldPosition.indexPath == newPosition.indexPath else { + return nil + } + return .update(itemID: itemID, indexPath: newPosition.indexPath) + } + + return deletes + inserts + sectionMembershipChanges + moves + updates + } + + private static func sectionMembershipChanges( + from oldSnapshot: WebInspectorFetchedResultsSnapshot, + to newSnapshot: WebInspectorFetchedResultsSnapshot, + oldPositions: [ItemID: ItemPosition], + newPositions: [ItemID: ItemPosition] + ) -> [WebInspectorFetchedResultsItemChange] { + let oldSectionIDs = Set(oldSnapshot.sectionIDs) + let newSectionIDs = Set(newSnapshot.sectionIDs) + let deletedSectionIDs = oldSectionIDs.subtracting(newSectionIDs) + let insertedSectionIDs = newSectionIDs.subtracting(oldSectionIDs) + + return newSnapshot.itemIDs.compactMap { itemID -> WebInspectorFetchedResultsItemChange? in + guard let oldPosition = oldPositions[itemID], + let newPosition = newPositions[itemID], + oldPosition.sectionID != newPosition.sectionID else { + return nil + } + let oldSectionDeleted = deletedSectionIDs.contains(oldPosition.sectionID) + let newSectionInserted = insertedSectionIDs.contains(newPosition.sectionID) + switch (oldSectionDeleted, newSectionInserted) { + case (true, true): + return nil + case (true, false): + return .insert(itemID: itemID, indexPath: newPosition.indexPath) + case (false, true): + return .delete(itemID: itemID, indexPath: oldPosition.indexPath) + case (false, false): + return .move( + itemID: itemID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ) + } + } + } + + private static func moveChanges( + from oldSnapshot: WebInspectorFetchedResultsSnapshot, + to newSnapshot: WebInspectorFetchedResultsSnapshot, + oldPositions: [ItemID: ItemPosition], + newPositions: [ItemID: ItemPosition], + updatedItemIDs: Set, + excludedItemIDs: Set + ) -> [WebInspectorFetchedResultsItemChange] { + let oldCommonOrder = oldSnapshot.itemIDs.filter { newPositions[$0] != nil } + let newCommonOrder = newSnapshot.itemIDs.filter { oldPositions[$0] != nil } + guard oldCommonOrder != newCommonOrder else { + return [] + } + + if updatedItemIDs.count == 1, + let changedID = updatedItemIDs.first, + excludedItemIDs.contains(changedID) == false, + let oldPosition = oldPositions[changedID], + let newPosition = newPositions[changedID], + oldPosition.sectionID == newPosition.sectionID, + oldPosition.indexPath != newPosition.indexPath { + return [ + .move( + itemID: changedID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ), + ] + } + + return newCommonOrder.compactMap { itemID -> WebInspectorFetchedResultsItemChange? in + guard excludedItemIDs.contains(itemID) == false else { + return nil + } + guard let oldPosition = oldPositions[itemID], + let newPosition = newPositions[itemID], + oldPosition.sectionID == newPosition.sectionID, + oldPosition.indexPath != newPosition.indexPath else { + return nil + } + return .move( + itemID: itemID, + from: oldPosition.indexPath, + to: newPosition.indexPath + ) + } + } + + private struct ItemPosition { + var itemID: ItemID + var sectionID: WebInspectorFetchSectionID + var indexPath: WebInspectorFetchedResultsIndexPath + } + + private static func indexItems( + _ snapshot: WebInspectorFetchedResultsSnapshot + ) -> [ItemID: ItemPosition] { + var positions: [ItemID: ItemPosition] = [:] + for (sectionIndex, section) in snapshot.sections.enumerated() { + for (itemIndex, itemID) in section.itemIDs.enumerated() where positions[itemID] == nil { + positions[itemID] = ItemPosition( + itemID: itemID, + sectionID: section.id, + indexPath: WebInspectorFetchedResultsIndexPath( + section: sectionIndex, + item: itemIndex + ) + ) + } + } + return positions + } + + private static func itemID( + for change: WebInspectorFetchedResultsItemChange + ) -> ItemID { + switch change { + case let .insert(itemID, _), + let .delete(itemID, _), + let .update(itemID, _), + let .move(itemID, _, _): + return itemID + } + } +} + +/// One atomic fetched-results publication. +/// +/// The initial value and every later transaction contain a complete identity +/// snapshot. Consumers that observe a revision gap can therefore replace their +/// local snapshot instead of applying a delta across missing revisions. +public enum WebInspectorFetchedResultsUpdate: Hashable, Sendable { + /// The complete result state that begins a subscription. + /// + /// If the producer advances before first consumption, the pending initial + /// value coalesces to the newest complete snapshot. + case initial( + revision: UInt64, + snapshot: WebInspectorFetchedResultsSnapshot + ) + + /// One later result publication. + /// + /// `reconfigureItemIDs` contains identities present in the new snapshot + /// whose model-backed presentation changed, including coalesced updates. + case transaction( + revision: UInt64, + transaction: WebInspectorFetchedResultsTransaction, + reconfigureItemIDs: Set + ) +} + +extension WebInspectorFetchedResultsUpdate { + fileprivate func coalescing( + _ pending: WebInspectorFetchedResultsUpdate + ) -> WebInspectorFetchedResultsUpdate { + switch (pending, self) { + case (_, .initial): + return self + case (.initial, .transaction(let revision, let transaction, _)): + return .initial( + revision: revision, + snapshot: transaction.newSnapshot + ) + case let ( + .transaction(_, _, pendingReconfigureItemIDs), + .transaction(revision, transaction, reconfigureItemIDs) + ): + return .transaction( + revision: revision, + transaction: transaction, + reconfigureItemIDs: reconfigureItemIDs + .union(pendingReconfigureItemIDs) + .intersection(transaction.newSnapshot.itemIDs) + ) + } + } +} + +private final class WebInspectorFetchedResultsUpdateSubscriber< + ItemID: Hashable & Sendable +>: Sendable { + typealias Update = WebInspectorFetchedResultsUpdate + + private struct State { + var pending: Update? + var waiters: [CheckedContinuation] = [] + var isFinished = false + } + + private struct Resumption { + var continuation: CheckedContinuation + var update: Update? + } + + private let state: Mutex + private let onTermination: @Sendable () -> Void + + init(initial: Update, onTermination: @escaping @Sendable () -> Void) { + state = Mutex(State(pending: initial)) + self.onTermination = onTermination + } + + func makeStream() -> AsyncStream { + AsyncStream( + unfolding: { [self] in + await next() + }, + onCancel: { [self] in + finish() + } + ) + } + + func offer(_ update: Update) { + let waiter = state.withLock { state -> CheckedContinuation? in + guard state.isFinished == false else { + return nil + } + if state.waiters.isEmpty == false { + return state.waiters.removeFirst() + } + if let pending = state.pending { + state.pending = update.coalescing(pending) + } else { + state.pending = update + } + return nil + } + waiter?.resume(returning: update) + } + + func finish() { + let resumptions = state.withLock { state -> [Resumption]? in + guard state.isFinished == false else { + return nil + } + state.isFinished = true + + var resumptions: [Resumption] = [] + if state.waiters.isEmpty == false, let pending = state.pending { + resumptions.append(Resumption( + continuation: state.waiters.removeFirst(), + update: pending + )) + state.pending = nil + } + resumptions.append(contentsOf: state.waiters.map { + Resumption(continuation: $0, update: nil) + }) + state.waiters.removeAll(keepingCapacity: false) + return resumptions + } + guard let resumptions else { + return + } + for resumption in resumptions { + resumption.continuation.resume(returning: resumption.update) + } + onTermination() + } + + private func next() async -> Update? { + if Task.isCancelled { + finish() + return nil + } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> (shouldResume: Bool, update: Update?) in + if let pending = state.pending { + state.pending = nil + return (true, pending) + } + if state.isFinished { + return (true, nil) + } + state.waiters.append(continuation) + return (false, nil) + } + if immediate.shouldResume { + continuation.resume(returning: immediate.update) + } + } + } onCancel: { [self] in + finish() + } + } + + deinit { + finish() + } +} + +private final class WeakWebInspectorFetchedResultsUpdateSubscriber< + ItemID: Hashable & Sendable +> { + weak var value: WebInspectorFetchedResultsUpdateSubscriber? + + init(_ value: WebInspectorFetchedResultsUpdateSubscriber) { + self.value = value + } +} + +final class WebInspectorFetchedResultsUpdateBroker: Sendable { + typealias Update = WebInspectorFetchedResultsUpdate + private typealias Subscriber = WebInspectorFetchedResultsUpdateSubscriber + + private struct State { + var subscribers: [UUID: WeakWebInspectorFetchedResultsUpdateSubscriber] = [:] + var isFinished = false + } + + private let state = Mutex(State()) + + func makeStream(initial: Update) -> AsyncStream { + let id = UUID() + let subscriber = Subscriber(initial: initial) { [weak self] in + self?.removeStream(id) + } + let shouldFinish = state.withLock { state in + guard state.isFinished == false else { + return true + } + state.subscribers = state.subscribers.filter { $0.value.value != nil } + state.subscribers[id] = WeakWebInspectorFetchedResultsUpdateSubscriber(subscriber) + return false + } + if shouldFinish { + subscriber.finish() + } + return subscriber.makeStream() + } + + func yield(_ update: Update) { + let subscribers = state.withLock { state -> [Subscriber] in + state.subscribers = state.subscribers.filter { $0.value.value != nil } + return state.subscribers.values.compactMap(\.value) + } + for subscriber in subscribers { + subscriber.offer(update) + } + } + + func finish() { + let subscribers = state.withLock { state -> [Subscriber] in + guard state.isFinished == false else { + return [] + } + state.isFinished = true + let subscribers = state.subscribers.values.compactMap(\.value) + state.subscribers.removeAll(keepingCapacity: false) + return subscribers + } + for subscriber in subscribers { + subscriber.finish() + } + } + + private func removeStream(_ id: UUID) { + _ = state.withLock { state in + state.subscribers.removeValue(forKey: id) + } + } + + deinit { + finish() + } +} + +extension WebInspectorFetchedResultsIndexPath: Comparable { + /// Orders index paths by section and then item. + public static func < ( + lhs: WebInspectorFetchedResultsIndexPath, + rhs: WebInspectorFetchedResultsIndexPath + ) -> Bool { + if lhs.section != rhs.section { + return lhs.section < rhs.section + } + return lhs.item < rhs.item + } +} diff --git a/Sources/WebInspectorDataKit/WebInspectorModelContext.swift b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift new file mode 100644 index 00000000..14ce5aaa --- /dev/null +++ b/Sources/WebInspectorDataKit/WebInspectorModelContext.swift @@ -0,0 +1,3395 @@ +import Foundation +import Observation +import Synchronization +import WebKit +import WebInspectorProxyKit + +/// Failures caused by the current semantic model state rather than transport. +public enum WebInspectorModelError: Error, Equatable, Sendable { + case detached + case synchronizing + case domainNotConfigured(WebInspectorModelContext.Domain) + case staleModel + case commandRejected(method: String, message: String) +} + +/// The one audited unchecked boundary required to deliver a Sendable feed +/// record back to a runtime-selected actor. +/// +/// This bridge owns no semantic state, terminal reason, task, feed, proxy, or +/// actor. Both references are weak, protected by one mutex, and the +/// context is dereferenced only inside one isolated-parameter hop. Those +/// invariants prevent it from becoming a second +/// model owner or a lifecycle edge. +private final class WebInspectorModelDeliveryBridge: @unchecked Sendable { + enum Request: Sendable { + case prepareAttachment(token: UInt64) + case prepareRecord(ConnectionModelFeedRecord, token: UInt64) + case commit(WebInspectorModelContext.ReducerWorkResult, token: UInt64) + case accept( + feed: ConnectionModelFeed, + proxy: WebInspectorProxy, + token: UInt64 + ) + case completeAttachment(token: UInt64) + case prepareFailure(WebInspectorModelContext.Failure, token: UInt64) + } + + enum Response: Sendable { + case prepared(WebInspectorModelContext.PreparedReducerStep?) + case committed(WebInspectorModelContext.ReducerCommitDecision) + case accepted(Bool) + } + + private let mutex = Mutex(()) + private weak var actor: (any Actor)? + private weak var context: WebInspectorModelContext? + + func bind( + _ context: WebInspectorModelContext, + isolation: isolated (any Actor) + ) { + mutex.withLock { _ in + if let existingActor = actor { + precondition( + existingActor === isolation, + "WebInspectorModelContext cannot move to another actor." + ) + } else { + actor = isolation + } + if let existingContext = self.context { + precondition( + existingContext === context, + "A model delivery bridge cannot be rebound." + ) + } else { + self.context = context + } + } + } + + func resolveActor() -> (any Actor)? { + mutex.withLock { _ in actor } + } + + func preconditionOwnerIsolation() { + resolveActor()?.preconditionIsolated( + "WebInspectorModelContext must be used by the actor that attached it." + ) + } + + private func resolveContext( + for isolation: any Actor + ) -> WebInspectorModelContext? { + mutex.withLock { _ -> WebInspectorModelContext? in + guard let actor else { + return nil + } + precondition( + actor === isolation, + "Model delivery ran on a foreign actor." + ) + return context + } + } + + func deliver( + _ request: Request, + isolation: isolated (any Actor) + ) -> Response? { + guard let context = resolveContext(for: isolation) else { + return nil + } + switch request { + case let .prepareAttachment(token): + return .prepared(context.prepareAttachment(token: token)) + case let .prepareRecord(record, token): + return .prepared(context.prepare(record, token: token)) + case let .commit(result, token): + return .committed(context.commit(result, token: token)) + case let .accept(feed, proxy, token): + return .accepted( + context.accept(feed: feed, proxy: proxy, token: token) + ) + case let .completeAttachment(token): + return .accepted(context.completeAttachment(token: token)) + case let .prepareFailure(failure, token): + return .prepared(context.prepareFailure(failure, token: token)) + } + } +} + +/// The identity-preserving model context for an inspected page. +/// +/// A context owns observable DOM, Network, Console, Runtime, and CSS models. +/// It is non-Sendable and becomes permanently confined to the actor that first +/// calls ``attach(to:isolation:)``. +@Observable +public final class WebInspectorModelContext { + package struct DOMUndoRedoCommands { + private weak var store: DOMStateStore? + private let target: WebInspectorTarget? + private let fallbackTarget: WebInspectorTarget? + private let documentEpoch: Int + + fileprivate init( + store: DOMStateStore, + target: WebInspectorTarget?, + fallbackTarget: WebInspectorTarget?, + documentEpoch: Int + ) { + self.store = store + self.target = target + self.fallbackTarget = fallbackTarget + self.documentEpoch = documentEpoch + } + + package nonisolated(nonsending) func undo() async throws { + try await undoRedoTarget().dom.undo() + } + + package nonisolated(nonsending) func redo() async throws { + try await undoRedoTarget().dom.redo() + } + + private func undoRedoTarget() throws -> WebInspectorTarget { + guard let store else { + throw WebInspectorProxyError.disconnected("WebInspectorDataKit context was released before DOM undo/redo.") + } + return try store.undoRedoTarget( + capturedTarget: target, + fallbackTarget: fallbackTarget, + documentEpoch: documentEpoch + ) + } + } + + package struct DOMDeletionPartialFailure: Error { + package let deletedNodeCount: Int + package let underlyingError: any Error + + package init(deletedNodeCount: Int, underlyingError: any Error) { + self.deletedNodeCount = deletedNodeCount + self.underlyingError = underlyingError + } + } + + /// A configured model domain. Construction is closed to the domains that + /// the ordered ProxyKit feed can actually provide. + public struct Domain: Hashable, Sendable { + fileprivate let rawValue: UInt8 + + private init(_ rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let dom = Domain(0) + public static let network = Domain(1) + public static let console = Domain(2) + public static let runtime = Domain(3) + public static let css = Domain(4) + } + + public struct Configuration: Sendable { + public let domains: Set + + public init( + domains: Set = [.dom, .network, .console, .runtime, .css] + ) { + if domains.contains(.css) { + self.domains = domains.union([.dom]) + } else { + self.domains = domains + } + } + } + + public struct PageGeneration: Hashable, Sendable { + package let rawValue: UInt64 + + package init(_ generation: WebInspectorPage.Generation) { + rawValue = generation.rawValue + } + } + + public enum ConnectionFailure: Equatable, Sendable { + case closed + case pageUnavailable + case protocolViolation(String) + case transport(String) + } + + public enum Failure: Error, Equatable, Sendable { + case connection(ConnectionFailure) + case bootstrap(domain: Domain, message: String) + } + + public enum TransitionError: Error, Equatable, Sendable { + case superseded + case closed + } + + /// The attachment state of a context. + public enum State: Equatable, Sendable { + case detached + case attaching + case synchronizing(PageGeneration) + case attached + case detaching + case closed + case failed(Failure) + } + + /// A compact status value suitable for UI binding. + public struct Status: Equatable, Sendable { + /// The current attachment state. + public let state: State + + /// The currently selected DOM node identity. + public let selectedNodeID: DOMNode.ID? + + /// A Boolean value indicating whether WebKit inspect mode is enabled. + public let isElementPickerEnabled: Bool + } + + private enum DOMTargetAuthority { + case awaiting(ModelDocumentEpoch) + case ready(ModelDocumentEpoch) + + var epoch: ModelDocumentEpoch { + switch self { + case let .awaiting(epoch), let .ready(epoch): + epoch + } + } + } + + private struct BindingState { + var generation: WebInspectorPage.Generation + var lastSequence: UInt64? + var targetSnapshotWasApplied: Bool + var currentPageID: WebInspectorTarget.ID? + var targets: [WebInspectorTarget.ID: ModelTarget] + var domAuthority: [WebInspectorTarget.ID: DOMTargetAuthority] + var completedDomains: Set + var didSynchronize: Bool + } + + private enum PendingReducerCommitAction { + case none + case failure(Failure) + case synchronizationComplete( + generation: WebInspectorPage.Generation, + through: UInt64 + ) + } + + private struct AttachmentTransition { + let token: UInt64 + let proxy: WebInspectorProxy + let completion: ReplyPromise + } + + fileprivate enum InspectorSelectionOutcome: Sendable { + case selected( + nodeID: DOM.Node.ID?, + expectedSelectionRevision: UInt64 + ) + case superseded + case failed(Failure) + } + + fileprivate enum ReducerWork: Sendable { + case none + case reset( + network: NetworkRequestStore.IndexWork, + console: ConsoleMessageStore.IndexWork, + pageHighlightDOM: DOM? + ) + case preparePageHighlightClear(DOM) + case pageHighlightClear(DOM) + case network(NetworkRequestStore.IndexWork) + case console(ConsoleMessageStore.IndexWork) + case networkAcknowledgement(NetworkRequestStore.IndexAcknowledgementWork) + case consoleAcknowledgement(ConsoleMessageStore.IndexAcknowledgementWork) + case acknowledgements( + network: NetworkRequestStore.IndexAcknowledgementWork?, + console: ConsoleMessageStore.IndexAcknowledgementWork?, + pageHighlightDOM: DOM? + ) + case inspectorSelection( + dom: DOM, + objectID: Runtime.RemoteObject.ID?, + feed: ConnectionModelFeed, + expectedSelectionRevision: UInt64 + ) + + func run(commit: ReducerCommit) async -> ReducerWorkResult { + switch self { + case .none: + return ReducerWorkResult(commit: commit, output: .none) + case let .reset(network, console, pageHighlightDOM): + let networkResult = await network.run() + let consoleResult = await console.run() + return ReducerWorkResult( + commit: commit, + output: .reset( + network: networkResult, + console: consoleResult, + pageHighlightDOM: pageHighlightDOM + ) + ) + case let .preparePageHighlightClear(dom): + return ReducerWorkResult( + commit: commit, + output: .pageHighlightClear(dom) + ) + case let .pageHighlightClear(dom): + await Self.clearPageHighlight(using: dom) + return ReducerWorkResult(commit: commit, output: .none) + case let .network(work): + return ReducerWorkResult( + commit: commit, + output: .network(await work.run()) + ) + case let .console(work): + return ReducerWorkResult( + commit: commit, + output: .console(await work.run()) + ) + case let .networkAcknowledgement(work): + await work.run() + return ReducerWorkResult(commit: commit, output: .none) + case let .consoleAcknowledgement(work): + await work.run() + return ReducerWorkResult(commit: commit, output: .none) + case let .acknowledgements(network, console, pageHighlightDOM): + await network?.run() + await console?.run() + await Self.clearPageHighlight(using: pageHighlightDOM) + return ReducerWorkResult(commit: commit, output: .none) + case let .inspectorSelection( + dom, + objectID, + feed, + expectedSelectionRevision + ): + let nodeID: DOM.Node.ID? + if let objectID { + do { + nodeID = try await dom.requestNode( + forRemoteObject: objectID + ) + } catch { + if WebInspectorModelContext.isPickerSupersession(error) { + return ReducerWorkResult( + commit: commit, + output: .inspectorSelection(.superseded) + ) + } + let operationError = error + let failure: Failure + do { + try await feed.releaseElementPicker() + failure = WebInspectorModelContext.mapAttachmentFailure( + operationError + ) + } catch { + failure = WebInspectorModelContext.mapAttachmentFailure( + WebInspectorScopeError( + operationError: operationError, + cleanupError: error + ) + ) + } + return ReducerWorkResult( + commit: commit, + output: .inspectorSelection(.failed(failure)) + ) + } + } else { + nodeID = nil + } + do { + try await feed.releaseElementPicker() + } catch { + return ReducerWorkResult( + commit: commit, + output: .inspectorSelection(.failed( + WebInspectorModelContext.mapAttachmentFailure(error) + )) + ) + } + return ReducerWorkResult( + commit: commit, + output: .inspectorSelection(.selected( + nodeID: nodeID, + expectedSelectionRevision: expectedSelectionRevision + )) + ) + } + } + + private static func clearPageHighlight(using dom: DOM?) async { + guard let dom else { + return + } + do { + try await dom.hideHighlight() + } catch { + WebInspectorDataKitLog.debug( + "DOM page highlight reset cleanup failed: \(String(describing: error))" + ) + } + } + } + + fileprivate enum ReducerCommit: Sendable { + case attachmentPrepared(token: UInt64) + case record(token: UInt64) + } + + fileprivate enum ReducerWorkOutput: Sendable { + case none + case reset( + network: NetworkRequestStore.IndexResult, + console: ConsoleMessageStore.IndexResult, + pageHighlightDOM: DOM? + ) + case pageHighlightClear(DOM) + case network(NetworkRequestStore.IndexResult) + case console(ConsoleMessageStore.IndexResult) + case inspectorSelection(InspectorSelectionOutcome) + } + + fileprivate struct PreparedReducerStep: Sendable { + let work: ReducerWork + let commit: ReducerCommit + + func run() async -> ReducerWorkResult { + await work.run(commit: commit) + } + } + + fileprivate struct ReducerWorkResult: Sendable { + let commit: ReducerCommit + let output: ReducerWorkOutput + } + + fileprivate struct ReducerCommitDecision: Sendable { + let accepted: Bool + let followup: ReducerWork? + let shouldContinue: Bool + } + + public let configuredDomains: Set + /// The current attachment state. + public private(set) var state: State + public private(set) var attachmentGeneration: UInt64 + public private(set) var pageGeneration: PageGeneration? + + @ObservationIgnored let cssInspectorBaselineStore: CSSInspectorBaselineStore + @ObservationIgnored private let domState: DOMStateStore + @ObservationIgnored private let runtimeState: RuntimeStateStore + @ObservationIgnored private let networkRequests: NetworkRequestStore + @ObservationIgnored private let consoleMessages: ConsoleMessageStore + @ObservationIgnored private let deliveryBridge: WebInspectorModelDeliveryBridge + @ObservationIgnored private var activeProxy: WebInspectorProxy? + @ObservationIgnored private var activeFeed: ConnectionModelFeed? + @ObservationIgnored private var binding: BindingState? + @ObservationIgnored private var attachmentTransition: AttachmentTransition? + @ObservationIgnored private var readinessCompletion: ReplyPromise? + @ObservationIgnored private var attachmentTask: Task? + @ObservationIgnored private var cleanupTask: Task? + @ObservationIgnored private var driverTask: Task? + @ObservationIgnored private var didPrepareAttachmentReset: Bool + @ObservationIgnored private var isTerminallyClosed: Bool + @ObservationIgnored private var pendingReducerCommitAction: PendingReducerCommitAction + @ObservationIgnored private var didCompleteInitialAttachment: Bool + @ObservationIgnored private var ownsElementPickerLease: Bool + @ObservationIgnored private var isElementPickerTransitioning: Bool + + /// The stable live DOM tree for the current document. + public var domTree: DOMTreeController { + get throws { + try requireConfigured(.dom) + return domState.rootTreeController() + } + } + + /// The current root DOM node, or `nil` while no document is loaded. + public var rootDOMNode: DOMNode? { + get throws { + try requireConfigured(.dom) + return domState.rootNode + } + } + + /// The currently selected DOM node. + public var selectedDOMNode: DOMNode? { + get throws { + try requireConfigured(.dom) + return domState.selectedNode + } + } + + /// A Boolean value indicating whether WebKit inspect mode is enabled. + public var isElementPickerEnabled: Bool { + get throws { + try requireConfigured(.dom) + return domState.isElementPickerEnabled + } + } + + /// Runtime execution contexts known to the current page. + public var runtimeContexts: [RuntimeContext] { + get throws { + try requireConfigured(.runtime) + return runtimeState.executionContexts + } + } + + @ObservationIgnored private var currentPage: WebInspectorTarget? + + @ObservationIgnored private let statusRelay: WebInspectorAsyncStreamRelay + + public init(configuration: Configuration = .init()) { + configuredDomains = configuration.domains + cssInspectorBaselineStore = CSSInspectorBaselineStore() + domState = DOMStateStore() + runtimeState = RuntimeStateStore() + networkRequests = NetworkRequestStore() + consoleMessages = ConsoleMessageStore() + deliveryBridge = WebInspectorModelDeliveryBridge() + state = .detached + attachmentGeneration = 0 + pageGeneration = nil + activeProxy = nil + activeFeed = nil + binding = nil + attachmentTransition = nil + readinessCompletion = nil + attachmentTask = nil + cleanupTask = nil + driverTask = nil + didPrepareAttachmentReset = false + isTerminallyClosed = false + pendingReducerCommitAction = .none + didCompleteInitialAttachment = false + ownsElementPickerLease = false + isElementPickerTransitioning = false + currentPage = nil + statusRelay = WebInspectorAsyncStreamRelay() + } + + @MainActor + public convenience init( + attachingTo webView: WKWebView, + configuration: Configuration = .init(), + proxyConfiguration: WebInspectorProxy.Configuration = .init() + ) async throws { + self.init(configuration: configuration) + let proxy = try await WebInspectorProxy( + attachingTo: webView, + configuration: proxyConfiguration + ) + do { + try await attach(to: proxy, isolation: MainActor.shared) + } catch { + await proxy.close() + throw error + } + } + + package static func preview( + configuration: Configuration = .init() + ) -> WebInspectorModelContext { + let context = WebInspectorModelContext(configuration: configuration) + context.state = .attached + return context + } + + deinit { + attachmentTask?.cancel() + cleanupTask?.cancel() + driverTask?.cancel() + } + + package var status: Status { + preconditionOwnerIsolation() + return Status( + state: state, + selectedNodeID: domState.selectedNode?.id, + isElementPickerEnabled: domState.isElementPickerEnabled + ) + } + + package var statusUpdates: AsyncStream { + statusRelay.makeStream(initialElement: status) + } + + /// Attaches the context to an exclusively owned ProxyKit connection. + /// + /// The call returns only after the ordered feed has applied its binding + /// synchronization boundary. Cancelling this caller cancels only its wait; + /// use ``detach()`` or ``close()`` to change resource state. + public func attach( + to proxy: WebInspectorProxy, + isolation: isolated (any Actor) = #isolation + ) async throws { + bindOwner(isolation) + switch state { + case .closed: + throw TransitionError.closed + case .attached where activeProxy === proxy: + return + case .attaching, .synchronizing: + if let transition = attachmentTransition, + transition.proxy === proxy { + try await transition.completion.value() + return + } + if activeProxy === proxy, let readinessCompletion { + try await readinessCompletion.value() + return + } + case .detached, .detaching, .failed, .attached: + break + } + + let completion = beginAttachment(to: proxy) + try await completion.value() + } + + public nonisolated(nonsending) func detach() async { + await tearDown(terminal: false) + } + + package nonisolated(nonsending) func detachIfAttached( + to proxy: WebInspectorProxy + ) async { + preconditionOwnerIsolation() + guard activeProxy === proxy else { + return + } + await tearDown(terminal: false) + } + + public nonisolated(nonsending) func close() async { + await tearDown(terminal: true) + } + + private func bindOwner(_ isolation: isolated (any Actor)) { + deliveryBridge.bind(self, isolation: isolation) + } + + package func preconditionOwnerIsolation() { + deliveryBridge.preconditionOwnerIsolation() + } + + private func beginAttachment( + to proxy: WebInspectorProxy + ) -> ReplyPromise { + precondition(!isTerminallyClosed, "A closed model context cannot attach.") + let token = advanceAttachmentToken() + readinessCompletion?.fulfill(.failure(TransitionError.superseded)) + attachmentTransition?.completion.fulfill(.failure(TransitionError.superseded)) + + let previousAttachmentTask = attachmentTask + let previousDriverTask = driverTask + let previousFeed = activeFeed + let previousProxy = activeProxy + previousAttachmentTask?.cancel() + previousDriverTask?.cancel() + attachmentTask = nil + driverTask = nil + activeFeed = nil + activeProxy = nil + currentPage = nil + binding = nil + pageGeneration = nil + didPrepareAttachmentReset = false + pendingReducerCommitAction = .none + ownsElementPickerLease = false + isElementPickerTransitioning = false + + let cleanup = Self.makeCleanupTask( + after: cleanupTask, + attachmentTask: previousAttachmentTask, + driverTask: previousDriverTask, + feed: previousFeed, + proxy: previousProxy + ) + cleanupTask = cleanup + + let completion = ReplyPromise() + attachmentTransition = AttachmentTransition( + token: token, + proxy: proxy, + completion: completion + ) + readinessCompletion = completion + transition(to: .attaching) + + let configuredDomains = configuredModelDomains + let bridge = deliveryBridge + let task = Self.makeAttachmentTask( + cleanup: cleanup, + proxy: proxy, + configuredDomains: configuredDomains, + token: token, + bridge: bridge + ) + attachmentTask = task + return completion + } + + private nonisolated static func makeAttachmentTask( + cleanup: Task?, + proxy: WebInspectorProxy, + configuredDomains: Set, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) -> Task { + Task.detached(priority: .userInitiated) { + await runAttachment( + cleanup: cleanup, + proxy: proxy, + configuredDomains: configuredDomains, + token: token, + bridge: bridge + ) + } + } + + private nonisolated static func runAttachment( + cleanup: Task?, + proxy: WebInspectorProxy, + configuredDomains: Set, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> Failure? { + if let cleanupFailure = await cleanup?.value { + await failAttachment( + cleanupFailure, + token: token, + bridge: bridge + ) + return nil + } + guard !Task.isCancelled, + let preparation = await prepareAttachmentStep( + token: token, + bridge: bridge + ) else { + return nil + } + let preparationResult = await preparation.run() + let preparationCommit = await commit( + preparationResult, + token: token, + bridge: bridge + ) + guard preparationCommit.accepted, !Task.isCancelled else { + return nil + } + if let followup = preparationCommit.followup { + _ = await followup.run(commit: .record(token: token)) + } + + let registrationWasAccepted = Mutex(false) + do { + _ = try await proxy.openModelFeed( + configuredDomains: configuredDomains, + onRegistered: { feed in + guard !Task.isCancelled else { + return false + } + let accepted = await accept( + feed: feed, + proxy: proxy, + token: token, + bridge: bridge + ) + registrationWasAccepted.withLock { value in + value = accepted + } + return accepted + } + ) + await completeAttachment(token: token, bridge: bridge) + return nil + } catch is CancellationError { + return nil + } catch { + if registrationWasAccepted.withLock({ $0 }) { + await completeAttachment(token: token, bridge: bridge) + return nil + } + await failAttachment( + mapAttachmentFailure(error), + token: token, + bridge: bridge + ) + return nil + } + } + + private static func prepareAttachmentStep( + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> PreparedReducerStep? { + guard let owner = bridge.resolveActor() else { + return nil + } + guard case let .prepared(step)? = await bridge.deliver( + .prepareAttachment(token: token), + isolation: owner + ) else { + return nil + } + return step + } + + private static func prepareRecordStep( + _ record: ConnectionModelFeedRecord, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> PreparedReducerStep? { + guard let owner = bridge.resolveActor() else { + return nil + } + guard case let .prepared(step)? = await bridge.deliver( + .prepareRecord(record, token: token), + isolation: owner + ) else { + return nil + } + return step + } + + private static func commit( + _ result: ReducerWorkResult, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> ReducerCommitDecision { + guard let owner = bridge.resolveActor() else { + return ReducerCommitDecision( + accepted: false, + followup: nil, + shouldContinue: false + ) + } + guard case let .committed(decision)? = await bridge.deliver( + .commit(result, token: token), + isolation: owner + ) else { + return ReducerCommitDecision( + accepted: false, + followup: nil, + shouldContinue: false + ) + } + return decision + } + + private static func accept( + feed: ConnectionModelFeed, + proxy: WebInspectorProxy, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> Bool { + guard let owner = bridge.resolveActor() else { + return false + } + guard case let .accepted(accepted)? = await bridge.deliver( + .accept(feed: feed, proxy: proxy, token: token), + isolation: owner + ) else { + return false + } + return accepted + } + + private static func completeAttachment( + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async { + guard let owner = bridge.resolveActor() else { + return + } + _ = await bridge.deliver( + .completeAttachment(token: token), + isolation: owner + ) + } + + private static func failAttachment( + _ failure: Failure, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async { + guard let step = await prepareFailureStep( + failure, + token: token, + bridge: bridge + ) else { + return + } + let result = await step.run() + let decision = await commit(result, token: token, bridge: bridge) + if let followup = decision.followup { + _ = await followup.run(commit: .record(token: token)) + } + } + + private static func prepareFailureStep( + _ failure: Failure, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async -> PreparedReducerStep? { + guard let owner = bridge.resolveActor() else { + return nil + } + guard case let .prepared(step)? = await bridge.deliver( + .prepareFailure(failure, token: token), + isolation: owner + ) else { + return nil + } + return step + } + + fileprivate func prepareAttachment( + token: UInt64 + ) -> PreparedReducerStep? { + guard attachmentTransition?.token == token else { + return nil + } + currentPage = nil + binding = nil + pageGeneration = nil + let resetWork = prepareSemanticReset() + return PreparedReducerStep( + work: resetWork, + commit: .attachmentPrepared(token: token) + ) + } + + fileprivate func accept( + feed: ConnectionModelFeed, + proxy: WebInspectorProxy, + token: UInt64 + ) -> Bool { + guard attachmentTransition?.token == token, + !isTerminallyClosed else { + return false + } + activeProxy = proxy + activeFeed = feed + + let records = feed.records + let bridge = deliveryBridge + driverTask = Self.makeDriverTask( + records, + token: token, + bridge: bridge + ) + return true + } + + fileprivate func completeAttachment(token: UInt64) -> Bool { + guard attachmentTransition?.token == token else { + return false + } + attachmentTask = nil + return true + } + + private nonisolated static func makeDriverTask( + _ records: ConnectionModelFeedRecords, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) -> Task { + Task.detached(priority: .userInitiated) { + await drive(records, token: token, bridge: bridge) + } + } + + private static func drive( + _ records: ConnectionModelFeedRecords, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async { + do { + for try await record in records { + try Task.checkCancellation() + guard let step = await prepareRecordStep( + record, + token: token, + bridge: bridge + ) else { + return + } + let result = await step.run() + let decision = await commit( + result, + token: token, + bridge: bridge + ) + guard decision.accepted else { + return + } + if let followup = decision.followup { + _ = await followup.run(commit: .record(token: token)) + } + guard decision.shouldContinue else { + return + } + } + guard !Task.isCancelled else { + return + } + await finishDriver( + .connection(.closed), + token: token, + bridge: bridge + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { + return + } + await finishDriver( + mapAttachmentFailure(error), + token: token, + bridge: bridge + ) + } + } + + private static func finishDriver( + _ failure: Failure, + token: UInt64, + bridge: WebInspectorModelDeliveryBridge + ) async { + await failAttachment(failure, token: token, bridge: bridge) + } + + fileprivate func commit( + _ result: ReducerWorkResult, + token: UInt64 + ) -> ReducerCommitDecision { + guard attachmentTransition?.token == token else { + return ReducerCommitDecision( + accepted: false, + followup: nil, + shouldContinue: false + ) + } + + let followup: ReducerWork? + var shouldContinue = true + switch result.output { + case .none: + followup = nil + case let .reset(networkResult, consoleResult, pageHighlightDOM): + followup = .acknowledgements( + network: networkRequests.commit(networkResult), + console: consoleMessages.commit(consoleResult), + pageHighlightDOM: pageHighlightDOM + ) + case let .pageHighlightClear(dom): + followup = .pageHighlightClear(dom) + case let .network(networkResult): + followup = networkRequests.commit( + networkResult + ).map(ReducerWork.networkAcknowledgement) + case let .console(consoleResult): + followup = consoleMessages.commit( + consoleResult + ).map(ReducerWork.consoleAcknowledgement) + case let .inspectorSelection(outcome): + switch outcome { + case .superseded: + followup = nil + case let .selected(nodeID, expectedSelectionRevision): + ownsElementPickerLease = false + isElementPickerTransitioning = false + if domState.selectionRevision == expectedSelectionRevision, + let nodeID { + applyDOMStateEffects( + domState.apply(.inspect(nodeID), modelContext: self) + ) + } else { + applyDOMStateEffects( + domState.setElementPickerEnabled(false) + ) + } + followup = nil + case let .failed(failure): + ownsElementPickerLease = false + isElementPickerTransitioning = false + pendingReducerCommitAction = .failure(failure) + followup = prepareSemanticReset() + shouldContinue = false + } + } + + switch result.commit { + case let .attachmentPrepared(expectedToken): + guard expectedToken == token else { + return ReducerCommitDecision( + accepted: false, + followup: nil, + shouldContinue: false + ) + } + didPrepareAttachmentReset = true + case let .record(expectedToken): + guard expectedToken == token else { + return ReducerCommitDecision( + accepted: false, + followup: nil, + shouldContinue: false + ) + } + finishPreparedRecord() + } + return ReducerCommitDecision( + accepted: true, + followup: followup, + shouldContinue: shouldContinue + ) + } + + private func transitionToFailure( + _ failure: Failure, + token: UInt64 + ) { + guard attachmentTransition?.token == token else { + return + } + invalidateBindingState() + attachmentTransition?.completion.fulfill(.failure(failure)) + readinessCompletion?.fulfill(.failure(failure)) + readinessCompletion = nil + transition(to: .failed(failure)) + } + + private nonisolated(nonsending) func tearDown(terminal: Bool) async { + preconditionOwnerIsolation() + if isTerminallyClosed { + return + } + let token = advanceAttachmentToken() + if terminal { + isTerminallyClosed = true + } + let transitionError: TransitionError = terminal ? .closed : .superseded + attachmentTransition?.completion.fulfill(.failure(transitionError)) + readinessCompletion?.fulfill(.failure(transitionError)) + attachmentTransition = nil + readinessCompletion = nil + transition(to: .detaching) + + let attachmentTask = attachmentTask + let driverTask = driverTask + let feed = activeFeed + let proxy = activeProxy + attachmentTask?.cancel() + driverTask?.cancel() + self.attachmentTask = nil + self.driverTask = nil + activeFeed = nil + activeProxy = nil + invalidateBindingState() + + let resetStep = PreparedReducerStep( + work: prepareSemanticReset(), + commit: .attachmentPrepared(token: token) + ) + let resetResult = await resetStep.run() + let resetFollowup = commitDetachedReset( + resetResult, + token: token + ) + if let resetFollowup { + _ = await resetFollowup.run(commit: .record(token: token)) + } + + let cleanup = Self.makeCleanupTask( + after: cleanupTask, + attachmentTask: attachmentTask, + driverTask: driverTask, + feed: feed, + proxy: proxy + ) + cleanupTask = cleanup + let cleanupFailure = await cleanup?.value + + guard attachmentGeneration == token else { + return + } + cleanupTask = nil + if let cleanupFailure { + transition(to: .failed(cleanupFailure)) + } else { + transition(to: terminal ? .closed : .detached) + } + } + + private func commitDetachedReset( + _ result: ReducerWorkResult, + token: UInt64 + ) -> ReducerWork? { + guard attachmentGeneration == token else { + return nil + } + switch result.output { + case let .reset(networkResult, consoleResult, pageHighlightDOM): + return .acknowledgements( + network: networkRequests.commit(networkResult), + console: consoleMessages.commit(consoleResult), + pageHighlightDOM: pageHighlightDOM + ) + case .none, .network, .console, .pageHighlightClear, .inspectorSelection: + preconditionFailure("A detached reset produced an invalid reducer result.") + } + } + + private static func makeCleanupTask( + after predecessor: Task?, + attachmentTask: Task?, + driverTask: Task?, + feed: ConnectionModelFeed?, + proxy: WebInspectorProxy? + ) -> Task? { + guard predecessor != nil || attachmentTask != nil || driverTask != nil + || feed != nil || proxy != nil else { + return nil + } + return Task.detached(priority: .userInitiated) { + var firstFailure = await predecessor?.value + if let attachmentFailure = await attachmentTask?.value, + firstFailure == nil { + firstFailure = attachmentFailure + } + await driverTask?.value + if let feed { + do { + try await feed.close() + } catch { + if firstFailure == nil { + firstFailure = mapAttachmentFailure(error) + } + } + } + await proxy?.close() + return firstFailure + } + } + + private func prepareSemanticReset( + ) -> ReducerWork { + domState.advanceDocumentEpoch() + let pageHighlightDOM = resetDOM() + runtimeState.reset() + let consoleReset = consoleMessages.prepareClearForLifecycle( + modelContext: self + ) + applyConsoleMessageEffects(consoleReset.effects) + let networkReset = networkRequests.prepareResetForNewAttachment() + return .reset( + network: networkRequests.indexWork(for: networkReset), + console: consoleMessages.indexWork(for: consoleReset.queryIndexReset), + pageHighlightDOM: pageHighlightDOM + ) + } + + private func invalidateBindingState() { + binding = nil + pageGeneration = nil + currentPage = nil + pendingReducerCommitAction = .none + ownsElementPickerLease = false + isElementPickerTransitioning = false + } + + fileprivate func prepareFailure( + _ failure: Failure, + token: UInt64 + ) -> PreparedReducerStep? { + guard attachmentTransition?.token == token, + !isTerminallyClosed, + state != .detaching else { + return nil + } + attachmentTask = nil + driverTask = nil + invalidateBindingState() + pendingReducerCommitAction = .failure(failure) + return PreparedReducerStep( + work: prepareSemanticReset(), + commit: .record(token: token) + ) + } + + fileprivate func prepare( + _ record: ConnectionModelFeedRecord, + token: UInt64 + ) -> PreparedReducerStep? { + guard attachmentTransition?.token == token, + !isTerminallyClosed else { + return nil + } + pendingReducerCommitAction = .none + + switch record { + case let .reset(generation): + if let binding, + generation.rawValue <= binding.generation.rawValue { + return prepareProtocolFailure( + "Model feed reset generations must increase.", + token: token + ) + } + let work: ReducerWork + if binding == nil && didPrepareAttachmentReset { + work = .none + } else { + work = prepareSemanticReset() + } + currentPage = nil + self.binding = BindingState( + generation: generation, + lastSequence: nil, + targetSnapshotWasApplied: false, + currentPageID: nil, + targets: [:], + domAuthority: [:], + completedDomains: [], + didSynchronize: false + ) + pageGeneration = PageGeneration(generation) + if didCompleteInitialAttachment { + readinessCompletion?.fulfill(.failure(TransitionError.superseded)) + readinessCompletion = ReplyPromise() + } + transition(to: .synchronizing(PageGeneration(generation))) + return PreparedReducerStep(work: work, commit: .record(token: token)) + + case let .targetSnapshot(generation, through, snapshot): + guard var binding = bindingForRecord(generation), + !binding.targetSnapshotWasApplied, + snapshot.targets.contains(where: { $0.id == snapshot.currentPageID }), + Set(snapshot.targets.map(\.id)).count == snapshot.targets.count else { + return prepareProtocolFailure( + "Model target snapshot was stale, duplicated, or missing its current page.", + token: token + ) + } + guard acceptWatermark(through, in: &binding) else { + return prepareProtocolFailure( + "Model target snapshot moved the feed watermark backwards.", + token: token + ) + } + binding.targetSnapshotWasApplied = true + binding.currentPageID = snapshot.currentPageID + binding.targets = Dictionary( + uniqueKeysWithValues: snapshot.targets.map { ($0.id, $0) } + ) + if configuredDomains.contains(.dom) { + binding.domAuthority = Dictionary( + uniqueKeysWithValues: snapshot.targets.map { + ($0.id, .awaiting(ModelDocumentEpoch(rawValue: 0))) + } + ) + } + self.binding = binding + guard let pageTarget = binding.targets[snapshot.currentPageID], + let target = authorizedTarget(pageTarget, documentEpoch: nil) else { + return prepareProtocolFailure( + "Model target snapshot could not construct current-page authority.", + token: token + ) + } + currentPage = target + return PreparedReducerStep(work: .none, commit: .record(token: token)) + + case let .domDocumentInvalidated(generation, sequence, target, documentEpoch): + guard var binding = bindingForSequencedRecord( + generation, + sequence: sequence + ), + configuredDomains.contains(.dom), + binding.targets[target.id] == target, + let previousAuthority = binding.domAuthority[target.id], + documentEpoch.rawValue == previousAuthority.epoch.rawValue + 1 else { + return prepareProtocolFailure( + "DOM document invalidation did not advance the registered target epoch exactly once.", + token: token + ) + } + binding.domAuthority[target.id] = .awaiting(documentEpoch) + self.binding = binding + let work: ReducerWork + if target.id == binding.currentPageID { + domState.advanceDocumentEpoch() + if let pageHighlightDOM = resetDOM() { + work = .preparePageHighlightClear(pageHighlightDOM) + } else { + work = .none + } + } else if let frameID = target.frameID { + cssInspectorBaselineStore.reset(targetID: target.id) + applyDOMStateEffects( + domState.detachProjectedFrameDocument( + forFrameID: frameID + ) + ) + work = .none + } else { + return prepareProtocolFailure( + "A non-page DOM target had no frame identity.", + token: token + ) + } + return PreparedReducerStep(work: work, commit: .record(token: token)) + + case let .event(generation, sequence, payload): + guard var binding = bindingForSequencedRecord( + generation, + sequence: sequence + ), binding.targetSnapshotWasApplied else { + return prepareProtocolFailure( + "A model event arrived outside an authoritative target snapshot.", + token: token + ) + } + self.binding = binding + let work = prepare( + payload, + binding: &binding, + token: token + ) + guard let work else { + return nil + } + if case .failure = pendingReducerCommitAction { + // `prepareProtocolFailure` already invalidated the binding. + } else { + self.binding = binding + } + return PreparedReducerStep(work: work, commit: .record(token: token)) + + case let .replayComplete(generation, domain, through): + guard var binding = bindingForRecord(generation), + binding.targetSnapshotWasApplied, + domain != .dom, + configuredModelDomains.contains(domain), + !binding.completedDomains.contains(domain), + acceptWatermark(through, in: &binding) else { + return prepareProtocolFailure( + "A replay completion marker was stale, duplicated, or unconfigured.", + token: token + ) + } + binding.completedDomains.insert(domain) + self.binding = binding + return PreparedReducerStep(work: .none, commit: .record(token: token)) + + case let .bootstrapSnapshot(generation, domain, sequence, payload): + guard domain == .dom, + configuredDomains.contains(.dom), + var binding = bindingForWatermarkedRecord( + generation, + sequence: sequence + ) else { + return prepareProtocolFailure( + "A DOM bootstrap snapshot was stale or unconfigured.", + token: token + ) + } + guard case let .domDocument(target, documentEpoch, root) = payload, + binding.targets[target.id] == target, + case let .awaiting(expectedEpoch)? = binding.domAuthority[target.id], + expectedEpoch == documentEpoch else { + return prepareProtocolFailure( + "A DOM bootstrap snapshot did not match its target epoch.", + token: token + ) + } + let effects: DOMStateStore.Effects? + if target.id == binding.currentPageID { + effects = domState.applyDocument( + root, + expectedEpoch: domState.documentEpoch, + reason: didCompleteInitialAttachment ? .pageChanged : .initialDocument, + modelContext: self + ) + } else { + effects = domState.applyFrameDocument( + root, + frameTargetID: target.id, + expectedEpoch: domState.documentEpoch, + modelContext: self + ) + } + guard let effects else { + return prepareProtocolFailure( + "A DOM bootstrap snapshot lost its local document epoch.", + token: token + ) + } + applyDOMStateEffects( + effects + ) + binding.domAuthority[target.id] = .ready(documentEpoch) + self.binding = binding + return PreparedReducerStep(work: .none, commit: .record(token: token)) + + case let .bootstrapComplete(generation, domain, through): + guard domain == .dom, + configuredDomains.contains(.dom), + var binding = bindingForRecord(generation), + binding.domAuthority.values.allSatisfy({ authority in + if case .ready = authority { return true } + return false + }), + acceptWatermark(through, in: &binding) else { + return prepareProtocolFailure( + "A DOM bootstrap completed before every target snapshot was applied.", + token: token + ) + } + if !binding.didSynchronize { + guard binding.completedDomains.insert(.dom).inserted else { + return prepareProtocolFailure( + "The initial DOM bootstrap completed more than once.", + token: token + ) + } + } + self.binding = binding + return PreparedReducerStep(work: .none, commit: .record(token: token)) + + case let .synchronizationComplete(generation, through): + guard var binding = bindingForRecord(generation), + binding.targetSnapshotWasApplied, + !binding.didSynchronize, + binding.completedDomains == configuredModelDomains, + acceptWatermark(through, in: &binding) else { + return prepareProtocolFailure( + "Binding synchronization completed before every configured domain.", + token: token + ) + } + self.binding = binding + pendingReducerCommitAction = .synchronizationComplete( + generation: generation, + through: through + ) + return PreparedReducerStep(work: .none, commit: .record(token: token)) + } + } + + private func prepare( + _ payload: ModelProtocolEvent, + binding: inout BindingState, + token: UInt64 + ) -> ReducerWork? { + switch payload { + case let .target(event): + switch event { + case let .targetCreated(target): + guard binding.targets[target.id] == nil else { + return prepareProtocolFailure( + "A model target was created twice.", + token: token + )?.work + } + binding.targets[target.id] = target + if configuredDomains.contains(.dom) { + binding.domAuthority[target.id] = .awaiting( + ModelDocumentEpoch(rawValue: 0) + ) + } + case let .targetDestroyed(target): + guard binding.targets.removeValue(forKey: target.id) != nil else { + return prepareProtocolFailure( + "A model target was destroyed without membership.", + token: token + )?.work + } + binding.domAuthority.removeValue(forKey: target.id) + cssInspectorBaselineStore.reset(targetID: target.id) + if let frameID = target.frameID { + applyDOMStateEffects( + domState.detachProjectedFrameDocument( + forFrameID: frameID + ) + ) + } + case let .didCommitProvisionalTarget(oldTargetID, newTarget): + binding.targets.removeValue(forKey: oldTargetID) + binding.domAuthority.removeValue(forKey: oldTargetID) + cssInspectorBaselineStore.reset(targetID: oldTargetID) + binding.targets[newTarget.id] = newTarget + if configuredDomains.contains(.dom) { + binding.domAuthority[newTarget.id] = .awaiting( + ModelDocumentEpoch(rawValue: 0) + ) + } + case .frameNavigated: + break + case let .frameDetached(frameID): + applyDOMStateEffects( + domState.detachProjectedFrameDocument( + forFrameID: frameID + ) + ) + } + return ReducerWork.none + + case let .dom(target, event): + guard configuredDomains.contains(.dom), + binding.targets[target.id] == target, + let authority = binding.domAuthority[target.id] else { + return prepareProtocolFailure( + "A DOM event referenced an unconfigured or foreign target.", + token: token + )?.work + } + guard case .ready = authority else { + return ReducerWork.none + } + if case .documentUpdated = event { + return prepareProtocolFailure( + "DOM.documentUpdated bypassed its authoritative invalidation record.", + token: token + )?.work + } + applyDOMStateEffects( + domState.apply(event, modelContext: self) + ) + return ReducerWork.none + + case let .inspector(target, event): + guard ownsElementPickerLease else { + return ReducerWork.none + } + guard configuredDomains.contains(.dom), + binding.targets[target.id] == target, + target.id == binding.currentPageID, + case let .ready(documentEpoch)? = binding.domAuthority[target.id], + let authorizedTarget = authorizedTarget( + target, + documentEpoch: documentEpoch + ), + let activeFeed else { + return prepareProtocolFailure( + "An Inspector event referenced an unauthorized document target.", + token: token + )?.work + } + guard case let .inspect(object, _) = event else { + return ReducerWork.none + } + let objectID: Runtime.RemoteObject.ID? = if object.subtype?.rawValue == "node" { + object.id + } else { + nil + } + return .inspectorSelection( + dom: authorizedTarget.dom, + objectID: objectID, + feed: activeFeed, + expectedSelectionRevision: domState.selectionRevision + ) + + case let .css(target, event): + guard configuredDomains.contains(.css), + binding.targets[target.id] == target, + case .ready? = binding.domAuthority[target.id] else { + if binding.targets[target.id] == target, + case .awaiting? = binding.domAuthority[target.id] { + return ReducerWork.none + } + return prepareProtocolFailure( + "A CSS event referenced an unconfigured or foreign target.", + token: token + )?.work + } + apply(event) + return ReducerWork.none + + case let .network(target, event): + guard configuredDomains.contains(.network), + binding.targets[target.id] == target else { + return prepareProtocolFailure( + "A Network event referenced an unconfigured or foreign target.", + token: token + )?.work + } + return networkRequests.prepareModelEvent( + event, + modelContext: self + ).map(ReducerWork.network) ?? ReducerWork.none + + case let .console(target, event): + guard configuredDomains.contains(.console), + binding.targets[target.id] == target else { + return prepareProtocolFailure( + "A Console event referenced an unconfigured or foreign target.", + token: token + )?.work + } + let prepared = consoleMessages.prepareModelEvent( + event, + targetID: target.id, + modelContext: self, + registerRuntimeObject: { payload in + runtimeState.registerConsoleParameter( + payload + ) + } + ) + applyConsoleMessageEffects(prepared.effects) + return prepared.indexWork.map(ReducerWork.console) ?? ReducerWork.none + + case let .runtime(target, event): + guard configuredDomains.contains(.runtime), + binding.targets[target.id] == target else { + return prepareProtocolFailure( + "A Runtime event referenced an unconfigured or foreign target.", + token: token + )?.work + } + runtimeState.apply( + event, + sourceTargetID: target.id, + isCurrentPageTarget: target.id == binding.currentPageID + ) + return ReducerWork.none + } + } + + private func finishPreparedRecord() { + let action = pendingReducerCommitAction + pendingReducerCommitAction = .none + switch action { + case .none: + break + case let .failure(failure): + guard let token = attachmentTransition?.token else { + return + } + transitionToFailure(failure, token: token) + case let .synchronizationComplete(generation, through): + guard var binding, + binding.generation == generation, + binding.lastSequence.map({ through >= $0 }) ?? true, + !binding.didSynchronize else { + preconditionFailure( + "A prepared model synchronization lost its reducer state." + ) + } + binding.didSynchronize = true + self.binding = binding + didCompleteInitialAttachment = true + if ownsElementPickerLease { + applyDOMStateEffects( + domState.setElementPickerEnabled(true) + ) + } + transition(to: .attached) + attachmentTransition?.completion.fulfill(.success(())) + readinessCompletion?.fulfill(.success(())) + readinessCompletion = nil + } + } + + private func prepareProtocolFailure( + _ message: String, + token: UInt64 + ) -> PreparedReducerStep? { + prepareFailure( + .connection(.protocolViolation(message)), + token: token + ) + } + + private func bindingForRecord( + _ generation: WebInspectorPage.Generation + ) -> BindingState? { + guard let binding, binding.generation == generation else { + return nil + } + return binding + } + + private func bindingForSequencedRecord( + _ generation: WebInspectorPage.Generation, + sequence: UInt64 + ) -> BindingState? { + guard var binding = bindingForRecord(generation), + binding.lastSequence.map({ sequence > $0 }) ?? true else { + return nil + } + binding.lastSequence = sequence + return binding + } + + private func bindingForWatermarkedRecord( + _ generation: WebInspectorPage.Generation, + sequence: UInt64 + ) -> BindingState? { + guard var binding = bindingForRecord(generation), + acceptWatermark(sequence, in: &binding) else { + return nil + } + return binding + } + + private func acceptWatermark( + _ sequence: UInt64, + in binding: inout BindingState + ) -> Bool { + guard binding.lastSequence.map({ sequence >= $0 }) ?? true else { + return false + } + binding.lastSequence = sequence + return true + } + + private func authorizedTarget( + _ target: ModelTarget, + documentEpoch: ModelDocumentEpoch? + ) -> WebInspectorTarget? { + guard let activeProxy, let activeFeed, let binding else { + return nil + } + let document = documentEpoch.map { + ConnectionModelCommandAuthorization.Document( + targetID: target.id, + epoch: $0 + ) + } + return activeProxy.modelTarget( + target, + authorization: ConnectionModelCommandAuthorization( + feedID: activeFeed.id, + generation: binding.generation, + document: document + ) + ) + } + + private func advanceAttachmentToken() -> UInt64 { + precondition( + attachmentGeneration < UInt64.max, + "WebInspectorModelContext exhausted its attachment generation." + ) + attachmentGeneration += 1 + return attachmentGeneration + } + + private var configuredModelDomains: Set { + Set(configuredDomains.map(Self.modelDomain)) + } + + private static func modelDomain(_ domain: Domain) -> ModelDomain { + switch domain.rawValue { + case Domain.dom.rawValue: + .dom + case Domain.network.rawValue: + .network + case Domain.console.rawValue: + .console + case Domain.runtime.rawValue: + .runtime + case Domain.css.rawValue: + .css + default: + preconditionFailure("A closed model domain had an unknown value.") + } + } + + private static func domain(_ domain: ModelDomain) -> Domain { + switch domain { + case .dom: + .dom + case .network: + .network + case .console: + .console + case .runtime: + .runtime + case .css: + .css + } + } + + private static func mapAttachmentFailure(_ error: any Error) -> Failure { + if let scopeError = error as? WebInspectorScopeError { + return .connection(.transport( + "Operation failed: \(String(describing: scopeError.operationError)); cleanup failed: \(String(describing: scopeError.cleanupError))" + )) + } + if let feedError = error as? ConnectionModelFeedError { + switch feedError { + case let .bootstrapFailed(domain, message): + return .bootstrap(domain: Self.domain(domain), message: message) + case .connectionAlreadyUsedByDirectConsumer: + return .connection(.protocolViolation( + "The Proxy connection was already used outside its model feed." + )) + case .alreadyOpen: + return .connection(.protocolViolation( + "The Proxy connection already owns a model feed." + )) + case .consumerTerminated: + return .connection(.transport("The model feed consumer terminated.")) + } + } + if let proxyError = error as? WebInspectorProxyError { + switch proxyError { + case .closed: + return .connection(.closed) + case .pageUnavailable: + return .connection(.pageUnavailable) + case let .protocolViolation(message): + return .connection(.protocolViolation(message)) + case let .transportFailure(message), let .disconnected(message), + let .attachFailed(message): + return .connection(.transport(message)) + case let .unsupported(features): + return .connection(.transport(features.joined(separator: ", "))) + case let .commandRejected(method, message): + return .connection(.transport("\(method): \(message)")) + case let .commandFailed(domain, method, message): + return .connection(.transport("\(domain).\(method): \(message)")) + case .staleIdentifier: + return .connection(.protocolViolation("Attachment became stale during startup.")) + case let .eventBufferOverflow(capacity): + return .connection(.transport( + "An auxiliary event subscriber exceeded its buffer capacity of \(capacity)." + )) + case .connectionInUse: + return .connection(.protocolViolation("The Proxy connection is already in use.")) + case let .timeout(domain, method): + return .connection(.transport("Timed out waiting for \(domain).\(method).")) + } + } + return .connection(.transport(String(describing: error))) + } + + private static func isPickerSupersession( + _ error: any Error + ) -> Bool { + guard let proxyError = error as? WebInspectorProxyError else { + return false + } + switch proxyError { + case .staleIdentifier, .pageUnavailable: + return true + case .unsupported, .attachFailed, .closed, .disconnected, + .commandFailed, .protocolViolation, .eventBufferOverflow, + .connectionInUse, .commandRejected, .transportFailure, + .timeout: + return false + } + } + + /// Returns the current DOM identity for an identifier. + public func domNode(id: DOMNode.ID) throws -> DOMNode? { + try requireConfigured(.dom) + return domState.node(for: id) + } + + package func requiredNode(for id: DOMNode.ID) throws -> DOMNode { + return try domState.requiredNode(for: id) + } + + /// Returns the current Network identity for an identifier. + public func networkRequest(id: NetworkRequest.ID) throws -> NetworkRequest? { + try requireConfigured(.network) + return networkRequests.request(for: id) + } + + package var networkRequestsCollectionState: NetworkRequestCollectionState { + networkRequests.collectionState + } + + package func registeredRequest( + forProxyID id: Network.Request.ID + ) -> NetworkRequest? { + return networkRequests.request(forProxyID: id) + } + + /// Clears retained Network requests and emits reset transactions. + public nonisolated(nonsending) func clearNetworkRequests() async { + preconditionOwnerIsolation() + guard configuredDomains.contains(.network) else { + return + } + await networkRequests.clear() + } + + /// Returns the registered Console message for an identifier. + package func registeredMessage( + for id: ConsoleMessage.ID + ) -> ConsoleMessage? { + return consoleMessages.message(for: id) + } + + /// Selects a DOM node and publishes the requested tree reveal intent. + public func selectDOMNode( + _ node: DOMNode?, + reveal: DOMRevealPolicy = .selectAndScroll + ) throws { + try requireConfigured(.dom) + if let node { + try registeredNode(node) + } + select(node, reveal: reveal) + } + + private func select( + _ node: DOMNode?, + reveal: DOMRevealPolicy + ) { + let effects = domState.select(node, reveal: reveal) + applyDOMStateEffects(effects) + } + + package func selectNode(_ id: DOMNode.ID) throws { + select(try requiredNode(for: id), reveal: .selectAndScroll) + } + + package func selectNode( + _ id: DOMNode.ID?, + reveal: DOMRevealPolicy + ) throws { + guard let id else { + select(nil, reveal: reveal) + return + } + select(try requiredNode(for: id), reveal: reveal) + } + + package func requestChildren( + for id: DOMNode.ID, + depth: Int = 1 + ) async throws { + try await requestDOMChildren(of: requiredNode(for: id), depth: depth) + } + + /// Requests child nodes without moving DOM selection. + public nonisolated(nonsending) func requestDOMChildren( + of node: DOMNode, + depth: Int = 1 + ) async throws { + precondition(depth >= 0, "DOM child request depth must be non-negative.") + try requireConfigured(.dom) + try registeredNode(node) + let target = try domTarget(owning: node.id.proxyID) + try await target.dom.requestChildNodes(node.id.proxyID, depth: depth) + } + + /// Sets one DOM attribute and returns its document-bound undo capability. + public nonisolated(nonsending) func setDOMAttribute( + _ name: String, + value: String, + on node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome { + try requireConfigured(.dom) + try registeredNode(node) + let target = try domTarget(owning: node.id.proxyID) + try await target.dom.setAttributeValue( + node.id.proxyID, + name: name, + value: value + ) + let options = DOMMutationPolicy(undo: undo) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + return DOMMutationOutcome( + requestedNodeIDs: [node.id], + appliedNodeIDs: [node.id], + failures: [], + undo: makeDOMUndoCapability(policy: undo) + ) + } + + /// Replaces one node's outer HTML and returns its undo capability. + public nonisolated(nonsending) func setOuterHTML( + _ html: String, + of node: DOMNode, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome { + try requireConfigured(.dom) + try registeredNode(node) + let target = try domTarget(owning: node.id.proxyID) + try await target.dom.setOuterHTML(node.id.proxyID, html: html) + let options = DOMMutationPolicy(undo: undo) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + return DOMMutationOutcome( + requestedNodeIDs: [node.id], + appliedNodeIDs: [node.id], + failures: [], + undo: makeDOMUndoCapability(policy: undo) + ) + } + + /// Removes the current subset of nodes and reports every node-specific failure. + public nonisolated(nonsending) func removeDOMNodes( + _ nodes: [DOMNode], + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMMutationOutcome { + try requireConfigured(.dom) + let deletion = try domState.sortedDeletionNodes(for: nodes) + let targets = try validatedDeletionTargets(for: deletion.nodes) + let options = DOMMutationPolicy(undo: undo) + var appliedNodeIDs: [DOMNode.ID] = [] + var failures: [DOMMutationFailure] = [] + for (node, target) in zip(deletion.nodes, targets) { + do { + try await target.dom.removeNode(node.id.proxyID) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + appliedNodeIDs.append(node.id) + } catch is CancellationError { + throw CancellationError() + } catch { + failures.append(DOMMutationFailure( + nodeID: node.id, + message: String(describing: error) + )) + } + } + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + appliedNodeIDs, + snapshot: deletion.snapshot + ) + ) + return DOMMutationOutcome( + requestedNodeIDs: nodes.map(\.id), + appliedNodeIDs: appliedNodeIDs, + failures: failures, + undo: appliedNodeIDs.isEmpty ? nil : makeDOMUndoCapability(policy: undo) + ) + } + + private func makeDOMUndoCapability( + policy: WebInspectorUndoPolicy + ) -> DOMUndoCapability? { + guard policy == .automatic else { + return nil + } + return DOMUndoCapability(commands: domUndoRedoCommands()) + } + + package func setDOMAttribute( + _ name: String, + value: String, + on id: DOMNode.ID, + options: DOMMutationPolicy + ) async throws { + let node = try requiredNode(for: id) + let target = try domTarget(owning: node.id.proxyID) + try await target.dom.setAttributeValue(node.id.proxyID, name: name, value: value) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + } + + package func setDOMOuterHTML( + _ html: String, + of id: DOMNode.ID, + options: DOMMutationPolicy + ) async throws { + let node = try requiredNode(for: id) + let target = try domTarget(owning: node.id.proxyID) + try await target.dom.setOuterHTML(node.id.proxyID, html: html) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + } + + package func removeDOMNodes( + _ nodeIDs: [DOMNode.ID], + options: DOMMutationPolicy + ) async throws -> DOMMutationOutcome { + let deletion = try domState.sortedDeletionNodes(for: nodeIDs) + let sortedNodes = deletion.nodes + let deletionTargets = try validatedDeletionTargets(for: sortedNodes) + var acceptedNodeIDs: [DOMNode.ID] = [] + for (node, target) in zip(sortedNodes, deletionTargets) { + do { + try await target.dom.removeNode(node.id.proxyID) + recordDOMEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + acceptedNodeIDs.append(node.id) + } catch { + if acceptedNodeIDs.isEmpty == false { + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + acceptedNodeIDs, + snapshot: deletion.snapshot + ) + ) + throw DOMDeletionPartialFailure( + deletedNodeCount: acceptedNodeIDs.count, + underlyingError: error + ) + } + throw error + } + } + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + acceptedNodeIDs, + snapshot: deletion.snapshot + ) + ) + let undo = acceptedNodeIDs.isEmpty + ? nil + : DOMUndoCapability(commands: domUndoRedoCommands()) + return DOMMutationOutcome( + requestedNodeIDs: nodeIDs, + appliedNodeIDs: acceptedNodeIDs, + failures: [], + undo: undo + ) + } + + /// Returns copied text for a DOM node in the requested format. + public nonisolated(nonsending) func copyText( + _ kind: DOMNode.CopyTextKind, + for node: DOMNode + ) async throws -> String { + try requireConfigured(.dom) + try registeredNode(node) + switch kind { + case .html: + let target = try domTarget(owning: node.id.proxyID) + return try await target.dom.outerHTML(of: node.id.proxyID) + case .selectorPath: + return try domState.currentTreeSnapshot( + containing: [node] + ).selectorPath(for: node.id) + case .xPath: + return try domState.currentTreeSnapshot( + containing: [node] + ).xPath(for: node.id) + } + } + + package func copyText( + _ kind: DOMNode.CopyTextKind, + for id: DOMNode.ID + ) async throws -> String { + try await copyText(kind, for: try requiredNode(for: id)) + } + + @discardableResult + private func deleteCountingRemovedNodes( + _ nodes: [DOMNode] + ) async throws -> Int { + let deletion = try domState.sortedDeletionNodes(for: nodes) + let sortedNodes = deletion.nodes + let deletionTargets = try validatedDeletionTargets(for: sortedNodes) + var removedNodes: [DOMNode] = [] + for (node, target) in zip(sortedNodes, deletionTargets) { + do { + try await target.dom.removeNode(node.id.proxyID) + recordDOMEditHistoryTarget(target, options: .init()) + try await target.dom.markUndoableState() + removedNodes.append(node) + } catch { + if removedNodes.isEmpty == false { + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + removedNodes.map(\.id), + snapshot: deletion.snapshot + ) + ) + throw DOMDeletionPartialFailure( + deletedNodeCount: removedNodes.count, + underlyingError: error + ) + } + throw error + } + } + applyDOMStateEffects( + domState.clearSelectionIfDeleted( + removedNodes.map(\.id), + snapshot: deletion.snapshot + ) + ) + return removedNodes.count + } + + package func delete(nodeIDs: [DOMNode.ID]) async throws { + _ = try await deleteCountingRemovedNodes(nodeIDs: nodeIDs) + } + + @discardableResult + package func deleteCountingRemovedNodes( + nodeIDs: [DOMNode.ID] + ) async throws -> Int { + var seenNodeIDs: Set = [] + let nodes = try nodeIDs + .filter { seenNodeIDs.insert($0).inserted } + .map { try domState.requiredNode(for: $0) } + return try await deleteCountingRemovedNodes(nodes) + } + + /// Highlights a DOM node in the inspected page. + public nonisolated(nonsending) func highlightDOMNode(_ node: DOMNode) async throws { + try requireConfigured(.dom) + try registeredNode(node) + let target = try domTarget(owning: node.id.proxyID) + if node.id.proxyID.targetScopeRawValue == nil { + domState.recordPageHighlight() + } + try await target.dom.highlightNode(node.id.proxyID) + } + + package func highlightNode(for id: DOMNode.ID) async throws { + try await highlightDOMNode(try requiredNode(for: id)) + } + + /// Clears the current DOM highlight in the inspected page. + public nonisolated(nonsending) func hideDOMHighlight() async throws { + try requireConfigured(.dom) + let targetID = try targetID(for: nil) + let target = try authorizedDocumentTarget(id: targetID) + try await target.dom.hideHighlight() + domState.clearPageHighlight() + } + + package func domUndoRedoCommands() -> DOMUndoRedoCommands { + return DOMUndoRedoCommands( + store: domState, + target: domState.capturedEditHistoryTarget(), + fallbackTarget: currentPage, + documentEpoch: domState.documentEpoch + ) + } + + package func undoDOMChange() async throws { + try await domUndoRedoCommands().undo() + } + + package func redoDOMChange() async throws { + try await domUndoRedoCommands().redo() + } + + /// Enables or disables WebKit's element picker. + public nonisolated(nonsending) func setElementPickerEnabled( + _ isEnabled: Bool + ) async throws { + try requireConfigured(.dom) + guard !isElementPickerTransitioning else { + throw WebInspectorModelError.commandRejected( + method: "DOM.setInspectModeEnabled", + message: "An element-picker transition is already in progress." + ) + } + if isEnabled == ownsElementPickerLease { + return + } + guard state == .attached, + let feed = activeFeed else { + throw modelStateError() + } + + let expectedAttachmentGeneration = attachmentGeneration + isElementPickerTransitioning = true + defer { + isElementPickerTransitioning = false + } + + if isEnabled { + var didAcquireLease = false + ownsElementPickerLease = true + do { + try await feed.acquireElementPicker() + didAcquireLease = true + guard attachmentGeneration == expectedAttachmentGeneration, + activeFeed === feed, + state == .attached else { + throw TransitionError.superseded + } + guard ownsElementPickerLease else { + return + } + applyDOMStateEffects( + domState.setElementPickerEnabled(true) + ) + } catch { + let operationError = error + ownsElementPickerLease = false + guard didAcquireLease else { + throw operationError + } + do { + try await feed.releaseElementPicker() + } catch { + throw WebInspectorScopeError( + operationError: operationError, + cleanupError: error + ) + } + throw operationError + } + return + } + + let cleanupResult: Result + do { + try await feed.releaseElementPicker() + cleanupResult = .success(()) + } catch { + cleanupResult = .failure(error) + } + ownsElementPickerLease = false + applyDOMStateEffects( + domState.setElementPickerEnabled(false) + ) + switch cleanupResult { + case .success: + return + case let .failure(cleanupError): + throw cleanupError + } + } + + /// Reloads the inspected page. + public nonisolated(nonsending) func reload( + ignoringCache: Bool = false + ) async throws { + preconditionOwnerIsolation() + let page = try currentPageOrThrow() + try await page.page.reload(ignoringCache: ignoringCache) + } + + /// Returns a CSS selector path for a DOM node. + public func selectorPath(for node: DOMNode) throws -> String { + try requireConfigured(.dom) + try registeredNode(node) + return try domState.currentTreeSnapshot( + containing: [node] + ).selectorPath(for: node.id) + } + + package func selectorPath(for id: DOMNode.ID) throws -> String { + try selectorPath(for: try requiredNode(for: id)) + } + + /// Returns an XPath expression for a DOM node. + public func xPath(for node: DOMNode) throws -> String { + try requireConfigured(.dom) + try registeredNode(node) + return try domState.currentTreeSnapshot( + containing: [node] + ).xPath(for: node.id) + } + + package func xPath(for id: DOMNode.ID) throws -> String { + try xPath(for: try requiredNode(for: id)) + } + + /// Creates a live DOM tree controller rooted at a current node. + public func domTree(rootedAt node: DOMNode) throws -> DOMTreeController { + try requireConfigured(.dom) + return try domState.treeController(root: node) + } + + package func rootTreeController() -> DOMTreeController { + return domState.rootTreeController() + } + + /// Runs an operation with one uniquely named binding-scoped Runtime group. + public nonisolated(nonsending) func withRuntimeObjectGroup( + named: String? = nil, + _ operation: nonisolated(nonsending) (RuntimeObjectGroup) async throws -> Output + ) async throws -> Output { + let objectGroup = try makeRuntimeObjectGroup(named: named) + let operationResult: Result + do { + operationResult = .success(try await operation(objectGroup)) + } catch { + operationResult = .failure(error) + } + + switch operationResult { + case let .success(output): + try await objectGroup.close() + return output + case let .failure(operationError): + do { + try await objectGroup.close() + } catch { + throw WebInspectorRuntimeScopeError( + operationError: operationError, + cleanupError: error + ) + } + throw operationError + } + } + + /// Creates live Network request results for a closed concrete query. + /// + /// The returned result already contains an atomic initial snapshot. Query + /// evaluation, ordering, sectioning, and windowing run on the Network index + /// actor rather than this context's owner actor. + public nonisolated(nonsending) func networkRequests( + matching query: NetworkQuery = NetworkQuery() + ) async throws -> WebInspectorFetchedResults { + try requireConfigured(.network) + return try await networkRequests.results( + matching: query, + modelContext: self + ) + } + + /// Creates live Console message results for a closed concrete query. + /// + /// The returned result already contains an atomic initial snapshot. Query + /// filtering, ordering, sectioning, and windowing run on the Console index + /// actor rather than this context's owner actor. + public nonisolated(nonsending) func consoleMessages( + matching query: ConsoleQuery = ConsoleQuery() + ) async throws -> WebInspectorFetchedResults { + try requireConfigured(.console) + return try await consoleMessages.results( + matching: query, + modelContext: self + ) + } + + /// Clears WebKit's Console object group and lets the ordered clear event + /// invalidate local message and remote-object identities. + public nonisolated(nonsending) func clearConsoleMessages() async throws { + try requireConfigured(.console) + let page = try currentPageOrThrow() + try await page.console.clearMessages() + } + + /// Loads and returns the request's stable response-body resource. + /// + /// Concurrent callers for the same body join one protocol request. Cancelling + /// one caller stops only that caller's wait; the shared request continues + /// until it completes or the body becomes stale. + public func responseBody( + for request: NetworkRequest, + isolation: isolated (any Actor) = #isolation + ) async throws -> NetworkBody { + try requireConfigured(.network) + guard networkRequests.request(for: request.id) === request else { + throw WebInspectorModelError.staleModel + } + let body = request.responseBody + let page: WebInspectorTarget? + if case .available = body.phase { + guard request.canFetchResponseBody else { + throw WebInspectorModelError.commandRejected( + method: "Network.getResponseBody", + message: "The response body is not available for this request." + ) + } + page = try currentPageOrThrow() + } else { + page = nil + } + + let lease: NetworkBody.ResponseFetchLease + switch body.acquireResponseFetch() { + case .loaded: + return body + case let .failed(error): + throw error + case let .waiter(existingLease): + lease = existingLease + case let .owner(newLease): + guard let page else { + preconditionFailure("A new response fetch requires a current page binding.") + } + lease = newLease + let requestID = request.proxyID + let backendResourceIdentifier = request.backendResourceIdentifier + let completion = newLease.completion + let task = Task { [weak body] in + _ = isolation + let result = await Self.loadResponseBody( + from: page, + requestID: requestID, + backendResourceIdentifier: backendResourceIdentifier + ) + guard let body else { + completion.fulfill(.failure(WebInspectorProxyError.staleIdentifier)) + return + } + body.finishResponseFetch(result, for: newLease) + } + body.installResponseFetchTask(task, for: newLease) + } + + _ = try await lease.completion.value() + guard networkRequests.request(for: request.id) === request, + request.responseBody === body else { + throw WebInspectorModelError.staleModel + } + return body + } + + nonisolated(nonsending) func updateNetworkQuery( + _ query: NetworkQuery, + for results: WebInspectorFetchedResults + ) async throws { + preconditionOwnerIsolation() + guard results.modelContext === self else { + preconditionFailure("Network fetched results are not registered in this WebInspectorModelContext.") + } + try await networkRequests.update(query, for: results) + } + + nonisolated(nonsending) func updateConsoleQuery( + _ query: ConsoleQuery, + for results: WebInspectorFetchedResults + ) async throws { + preconditionOwnerIsolation() + guard results.modelContext === self else { + preconditionFailure("Console fetched results are not registered in this WebInspectorModelContext.") + } + try await consoleMessages.update(query, for: results) + } + + private nonisolated static func loadResponseBody( + from page: WebInspectorTarget, + requestID: Network.Request.ID, + backendResourceIdentifier: Network.BackendResourceID? + ) async -> Result { + do { + return .success(try await page.network.responseBody( + for: requestID, + backendResourceIdentifier: backendResourceIdentifier + )) + } catch is CancellationError { + return .failure(.staleIdentifier) + } catch let error as WebInspectorProxyError { + return .failure(error) + } catch { + return .failure(.commandFailed( + domain: "Network", + method: "getResponseBody", + message: String(describing: error) + )) + } + } + + private func makeRuntimeObjectGroup( + named name: String? + ) throws -> RuntimeObjectGroup { + try requireConfigured(.runtime) + guard state == .attached, + let binding, + binding.didSynchronize, + let target = currentPage else { + throw modelStateError() + } + let id = runtimeState.createGroupID() + let label = name.map(Self.runtimeObjectGroupLabel) ?? "group" + let wireName = "WebInspectorDataKit.\(attachmentGeneration).\(binding.generation.rawValue).\(id.rawValue).\(label)" + return RuntimeObjectGroup( + modelContext: self, + id: id, + target: target, + wireGroup: .other(wireName), + attachmentGeneration: attachmentGeneration, + pageGeneration: binding.generation + ) + } + + package nonisolated(nonsending) func evaluate( + _ expression: String, + in context: RuntimeContext?, + objectGroup: RuntimeObjectGroup + ) async throws -> RuntimeEvaluation { + try validate(objectGroup) + let evaluationBinding = try runtimeState.evaluationBinding(for: context) + let result = try await objectGroup.target.runtime.evaluate( + expression, + in: evaluationBinding.executionContextID, + objectGroup: objectGroup.wireGroup + ) + try validate(objectGroup) + return try runtimeState.finishEvaluation( + result, + binding: evaluationBinding, + groupID: objectGroup.id + ) + } + + package nonisolated(nonsending) func properties( + of object: RuntimeObject, + ownProperties: Bool, + objectGroup: RuntimeObjectGroup + ) async throws -> [RuntimeProperty] { + try validate(objectGroup) + guard let objectBinding = try runtimeState.objectBinding( + for: object, + groupID: objectGroup.id + ) else { + return [] + } + let descriptors = try await objectGroup.target.runtime.properties( + of: objectBinding.remoteID, + ownProperties: ownProperties + ) + try validate(objectGroup) + return try runtimeState.finishProperties( + descriptors, + binding: objectBinding, + groupID: objectGroup.id + ) + } + + package nonisolated(nonsending) func preview( + of object: RuntimeObject, + objectGroup: RuntimeObjectGroup + ) async throws -> RuntimeObjectPreview { + try validate(objectGroup) + guard let objectBinding = try runtimeState.objectBinding( + for: object, + groupID: objectGroup.id + ) else { + throw WebInspectorModelError.staleModel + } + let preview = try await objectGroup.target.runtime.preview( + of: objectBinding.remoteID + ) + try validate(objectGroup) + _ = try runtimeState.objectBinding( + for: object, + groupID: objectGroup.id + ) + return preview + } + + package nonisolated(nonsending) func close( + objectGroup: RuntimeObjectGroup + ) async throws { + preconditionOwnerIsolation() + guard objectGroup.modelContext === self else { + throw WebInspectorModelError.staleModel + } + guard isCurrent(objectGroup) else { + runtimeState.invalidateGroup(objectGroup.id) + return + } + do { + try await objectGroup.target.runtime.releaseObjectGroup( + objectGroup.wireGroup + ) + runtimeState.invalidateGroup(objectGroup.id) + } catch WebInspectorProxyError.staleIdentifier { + runtimeState.invalidateGroup(objectGroup.id) + return + } + } + + private func validate(_ objectGroup: RuntimeObjectGroup) throws { + preconditionOwnerIsolation() + guard isCurrent(objectGroup) else { + throw WebInspectorModelError.staleModel + } + } + + private func isCurrent(_ objectGroup: RuntimeObjectGroup) -> Bool { + guard objectGroup.modelContext === self, + runtimeState.isActiveGroup(objectGroup.id), + objectGroup.attachmentGeneration == attachmentGeneration, + let binding, + binding.didSynchronize, + binding.generation == objectGroup.pageGeneration, + currentPage?.id == objectGroup.target.id, + state == .attached else { + return false + } + return true + } + + private static func runtimeObjectGroupLabel(_ name: String) -> String { + let scalars = name.unicodeScalars.prefix(64).map { scalar -> Character in + CharacterSet.alphanumerics.contains(scalar) ? Character(String(scalar)) : "_" + } + return scalars.isEmpty ? "group" : String(scalars) + } + + @discardableResult + private func registeredNode( + _ node: DOMNode + ) throws -> DOMNode { + try domState.registeredNode(node) + } + + private func currentPageOrThrow() throws -> WebInspectorTarget { + guard let currentPage else { + throw modelStateError() + } + return currentPage + } + + private func domTarget(owning id: DOM.Node.ID) throws -> WebInspectorTarget { + let targetID: WebInspectorTarget.ID + if let scopedTargetRawValue = id.targetScopeRawValue { + targetID = WebInspectorTarget.ID(scopedTargetRawValue) + } else if let currentPageID = binding?.currentPageID { + targetID = currentPageID + } else { + throw modelStateError() + } + return try authorizedDocumentTarget(id: targetID) + } + + private func cssTarget(owning id: CSS.Style.ID) throws -> WebInspectorTarget { + try authorizedDocumentTarget(id: targetID(for: id.targetScopeRawValue)) + } + + private func cssTarget(owning id: CSS.Rule.ID) throws -> WebInspectorTarget { + try authorizedDocumentTarget(id: targetID(for: id.targetScopeRawValue)) + } + + private func cssTarget(owning id: CSS.StyleSheet.ID) throws -> WebInspectorTarget { + try authorizedDocumentTarget(id: targetID(for: id.targetScopeRawValue)) + } + + private func targetID(for scopedRawValue: String?) throws -> WebInspectorTarget.ID { + if let scopedRawValue { + return WebInspectorTarget.ID(scopedRawValue) + } + guard let currentPageID = binding?.currentPageID else { + throw modelStateError() + } + return currentPageID + } + + private func authorizedDocumentTarget( + id targetID: WebInspectorTarget.ID + ) throws -> WebInspectorTarget { + guard let binding, + let target = binding.targets[targetID], + case let .ready(epoch)? = binding.domAuthority[targetID], + let authorized = authorizedTarget(target, documentEpoch: epoch) else { + throw WebInspectorModelError.staleModel + } + return authorized + } + + private func modelStateError() -> any Error { + switch state { + case .detached, .detaching: + WebInspectorModelError.detached + case .attaching, .synchronizing: + WebInspectorModelError.synchronizing + case .failed(let failure): + failure + case .closed: + TransitionError.closed + case .attached: + WebInspectorModelError.staleModel + } + } + + private func requireConfigured(_ domain: Domain) throws { + preconditionOwnerIsolation() + guard configuredDomains.contains(domain) else { + throw WebInspectorModelError.domainNotConfigured(domain) + } + if case let .failed(failure) = state { + throw failure + } + } + + private static func markDOMUndoableStateIfNeeded( + on target: WebInspectorTarget, + options: DOMMutationPolicy + ) async throws { + switch options.undo { + case .automatic: + try await target.dom.markUndoableState() + case .disabled: + break + } + } + + private func recordDOMEditHistoryTarget( + _ target: WebInspectorTarget, + options: DOMMutationPolicy + ) { + domState.recordEditHistoryTarget(target, options: options) + } + + private func validatedDeletionTargets(for nodes: [DOMNode]) throws -> [WebInspectorTarget] { + var deletionTargets: [WebInspectorTarget] = [] + var firstTargetID: WebInspectorTarget.ID? + for node in nodes { + let target = try domTarget(owning: node.id.proxyID) + if let firstTargetID, firstTargetID != target.id { + throw WebInspectorProxyError.commandFailed( + domain: "DOM", + method: "removeNode", + message: "Deleting nodes from multiple DOM targets in one mutation is not supported." + ) + } + firstTargetID = target.id + deletionTargets.append(target) + } + return deletionTargets + } + + /// Inbound events may reference entities this context has not materialized: + /// WebKit only reports what it has bound for this frontend, but binding can + /// predate domain tracking (attach mid-flight) or outlive this context's + /// index (evicted subtrees). Skipping is the protocol-correct response; + /// `state = .failed` is reserved for terminal connection loss. + private func skipEvent(_ reason: String) { + WebInspectorDataKitLog.debug("event skipped: \(reason)") + } + + private func failIfTerminal(_ error: Error, operation: String) { + WebInspectorDataKitLog.debug("\(operation) failed: \(String(describing: error))") + } + + private func transition(to newState: State) { + state = newState + notifyStatusChanged() + WebInspectorDataKitLog.debug("context state=\(newState.logDescription)") + } + + private func notifyStatusChanged() { + guard statusRelay.hasContinuations else { + return + } + statusRelay.yield(status) + } + + private func applyDOMStateEffects(_ effects: DOMStateStore.Effects) { + effects.discardedStyleNode?.setElementStyles(nil) + + if effects.statusChanged { + notifyStatusChanged() + } + if effects.selectedStylesNeedRefresh { + markSelectedStylesNeedsRefresh() + } + } + +} + +extension WebInspectorModelContext { + func apply(_ event: DOM.Event) { + let effects = domState.apply(event, modelContext: self) + applyDOMStateEffects(effects) + } + + func applyDocument( + _ node: DOM.Node, + expectedEpoch: Int, + reason: DOMTreeSnapshotReason = .initialDocument + ) { + guard let effects = domState.applyDocument( + node, + expectedEpoch: expectedEpoch, + reason: reason, + modelContext: self + ) else { + return + } + applyDOMStateEffects(effects) + } + + package func seedDOMDocument( + _ node: DOM.Node + ) { + let reason: DOMTreeSnapshotReason = domState.rootNode == nil ? .initialDocument : .documentUpdated + applyDocument( + node, + expectedEpoch: domState.documentEpoch, + reason: reason + ) + } + + package func seedElementPickerEnabled( + _ isEnabled: Bool + ) { + applyDOMStateEffects( + domState.setElementPickerEnabled(isEnabled) + ) + } + + /// Seeds the selected element node's stable CSS resource. + package func seedSelectedNodeStyles( + matchedStyles: CSS.MatchedStyles, + inlineStyles: CSS.InlineStyles? = nil, + computedProperties: [CSS.ComputedProperty] = [] + ) { + guard let selectedNode = domState.selectedNode else { + preconditionFailure("seedSelectedNodeStyles requires a selected node.") + } + guard selectedNode.nodeType == 1 else { + preconditionFailure("seedSelectedNodeStyles requires a selected element node.") + } + let styles = selectedNode.elementStyles ?? CSSStyles(nodeID: selectedNode.id, modelContext: self) + selectedNode.setElementStyles(styles) + styles.load( + matchedStyles: matchedStyles, + inlineStyles: inlineStyles ?? CSS.InlineStyles(), + computedProperties: computedProperties + ) + } + + private func resetDOM() -> DOM? { + cssInspectorBaselineStore.reset() + let effects = domState.resetDocument() + let pageHighlightDOM = effects.shouldClearPageHighlight + ? currentPage?.dom + : nil + applyDOMStateEffects(effects) + return pageHighlightDOM + } +} +extension WebInspectorModelContext { + /// Loads the stable CSS resource owned by a DOM element without changing + /// DOM selection. + public nonisolated(nonsending) func cssStyles( + for node: DOMNode + ) async throws -> CSSStyles { + try requireConfigured(.css) + try registeredNode(node) + guard node.nodeType == 1 else { + throw WebInspectorModelError.commandRejected( + method: "CSS.getMatchedStylesForNode", + message: "CSS styles are only available for element DOM nodes." + ) + } + if let styles = node.elementStyles { + switch styles.phase { + case .loaded, .needsRefresh: + return styles + case .loading: + preconditionFailure("Concurrent initial CSS loads require one caller-owned task.") + case .failed, .unavailable: + try await loadCSSStyles(for: node, into: styles) + return styles + } + } + let styles = CSSStyles(nodeID: node.id, modelContext: self) + node.setElementStyles(styles) + try await loadCSSStyles(for: node, into: styles) + return styles + } + + /// Explicitly refreshes a visible CSS resource after it becomes stale. + public nonisolated(nonsending) func refreshCSSStyles( + for node: DOMNode + ) async throws { + try requireConfigured(.css) + try registeredNode(node) + guard let styles = node.elementStyles else { + _ = try await cssStyles(for: node) + return + } + try await loadCSSStyles(for: node, into: styles) + } + + private nonisolated(nonsending) func loadCSSStyles( + for node: DOMNode, + into styles: CSSStyles + ) async throws { + try await styles.withExclusiveOperation { + try await loadCSSStylesExclusively(for: node, into: styles) + } + } + + private nonisolated(nonsending) func loadCSSStylesExclusively( + for node: DOMNode, + into styles: CSSStyles + ) async throws { + let target = try domTarget(owning: node.id.proxyID) + styles.markLoading() + do { + let matched = try await target.css.matchedStyles(for: node.id.proxyID) + try registeredNode(node) + let inline = try await target.css.inlineStyles(for: node.id.proxyID) + try registeredNode(node) + let computed = try await target.css.computedStyle(for: node.id.proxyID) + try registeredNode(node) + styles.load( + matchedStyles: matched, + inlineStyles: inline, + computedProperties: computed + ) + } catch is CancellationError { + // Preserve task cancellation so CSSStyles' operation boundary can + // restore the last usable resource phase. + throw CancellationError() + } catch let error as WebInspectorProxyError { + styles.fail(error) + throw error + } catch { + let proxyError = WebInspectorProxyError.commandFailed( + domain: "CSS", + method: "getMatchedStylesForNode/getInlineStylesForNode/getComputedStyleForNode", + message: String(describing: error) + ) + styles.fail(proxyError) + throw proxyError + } + } + + public nonisolated(nonsending) func setCSSProperty( + _ property: CSSStyleProperty, + enabled: Bool, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? { + try requireConfigured(.css) + guard let styles = domState.styles(containing: property), + property.beginMutation() else { + throw WebInspectorModelError.staleModel + } + defer { + property.endMutation() + } + return try await styles.withExclusiveOperation { + try await loadCSSStylesForMutationIfNeeded(styles) + guard let intent = styles.setStyleTextIntent( + for: property, + enabled: enabled + ) else { + throw WebInspectorModelError.staleModel + } + let target = try cssTarget(owning: intent.styleID) + let result = try await target.css.setStyleText(intent.styleID, text: intent.text) + styles.applySetStyleText(result: result, for: property.id) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + return makeDOMUndoCapability(policy: undo) + } + } + + public nonisolated(nonsending) func setCSSDeclarationText( + _ text: String, + for property: CSSStyleProperty, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? { + try requireConfigured(.css) + guard let styles = domState.styles(containing: property), + property.beginMutation() else { + throw WebInspectorModelError.staleModel + } + defer { + property.endMutation() + } + return try await styles.withExclusiveOperation { + try await loadCSSStylesForMutationIfNeeded(styles) + guard let intent = styles.setDeclarationTextIntent( + for: property, + text: text + ) else { + throw WebInspectorModelError.staleModel + } + let target = try cssTarget(owning: intent.styleID) + let result = try await target.css.setStyleText(intent.styleID, text: intent.text) + styles.applySetStyleText(result: result, for: property.id) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + return makeDOMUndoCapability(policy: undo) + } + } + + private nonisolated(nonsending) func loadCSSStylesForMutationIfNeeded( + _ styles: CSSStyles + ) async throws { + guard styles.phase != .loaded else { + return + } + guard let node = domState.node(for: styles.id.nodeID), + node.elementStyles === styles else { + throw WebInspectorModelError.staleModel + } + try await loadCSSStylesExclusively(for: node, into: styles) + } + + public nonisolated(nonsending) func setCSSRuleSelector( + _ selector: String, + for rule: CSSStyleRule, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? { + try requireConfigured(.css) + guard let id = rule.id, + let styles = domState.styles(containing: id) else { + throw WebInspectorModelError.staleModel + } + let target = try cssTarget(owning: id.proxyID) + _ = try await target.css.setRuleSelector(id.proxyID, selector: selector) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + styles.markNeedsRefresh() + return makeDOMUndoCapability(policy: undo) + } + + public nonisolated(nonsending) func setCSSStyleSheetText( + _ text: String, + for styleSheetID: CSS.StyleSheet.ID, + undo: WebInspectorUndoPolicy = .automatic + ) async throws -> DOMUndoCapability? { + try requireConfigured(.css) + let target = try cssTarget(owning: styleSheetID) + try await target.css.setStyleSheetText(styleSheetID, text: text) + let options = DOMMutationPolicy(undo: undo) + domState.recordEditHistoryTarget(target, options: options) + try await Self.markDOMUndoableStateIfNeeded(on: target, options: options) + domState.markAllStylesNeedsRefresh() + return makeDOMUndoCapability(policy: undo) + } + + func apply(_ event: CSS.Event) { + switch event { + case .styleSheetChanged, + .styleSheetAdded, + .styleSheetRemoved, + .mediaQueryResultChanged: + domState.markAllStylesNeedsRefresh() + case let .nodeLayoutFlagsChanged(id): + domState.node(for: DOMNode.ID(id))?.elementStyles?.markNeedsRefresh() + case .unknown: + break + } + } + + private func markSelectedStylesNeedsRefresh() { + domState.selectedNode?.elementStyles?.markNeedsRefresh() + } +} + +extension WebInspectorModelContext { + @discardableResult + package func seedNetworkRequest( + requestID rawRequestID: String, + url: String, + method: String = "GET", + resourceTypeRawValue: String?, + requestHeaders: [String: String] = [:], + postData: String? = nil, + responseMIMEType: String, + responseStatus: Int, + responseStatusText: String, + responseHeaders: [String: String] = [:], + responseBody: String? = nil, + timestamp: Double, + encodedBodyLength: Int = 0 + ) -> NetworkRequest.ID { + return networkRequests.seedRequest( + requestID: rawRequestID, + url: url, + method: method, + resourceTypeRawValue: resourceTypeRawValue, + requestHeaders: requestHeaders, + postData: postData, + responseMIMEType: responseMIMEType, + responseStatus: responseStatus, + responseStatusText: responseStatusText, + responseHeaders: responseHeaders, + responseBody: responseBody, + timestamp: timestamp, + encodedBodyLength: encodedBodyLength, + modelContext: self + ) + } + + package func seedResponseBody( + for requestID: NetworkRequest.ID, + body: String, + base64Encoded: Bool = false, + size: Int? = nil, + isTruncated: Bool = false + ) { + networkRequests.seedResponseBody( + for: requestID, + body: body, + base64Encoded: base64Encoded, + size: size, + isTruncated: isTruncated + ) + } + + package nonisolated(nonsending) func apply( + _ event: Network.Event + ) async { + await networkRequests.apply(event, modelContext: self) + } +} + +extension WebInspectorModelContext { + func apply( + _ event: Console.Event, + targetID: WebInspectorTarget.ID? = nil + ) async { + let effects = await consoleMessages.apply( + event, + targetID: targetID, + modelContext: self, + registerRuntimeObject: { [self] payload in + runtimeState.registerConsoleParameter( + payload + ) + } + ) + // WebKit's Console agent releases its "console" Runtime object group + // before emitting Console.messagesCleared. DataKit owns only the local + // RuntimeObject registrations invalidated by that event. + applyConsoleMessageEffects(effects) + } + + private func applyConsoleMessageEffects( + _ effects: ConsoleMessageStore.Effects + ) { + if effects.clearedAllMessages { + runtimeState.removeAllConsoleOwnership() + return + } + runtimeState.removeConsoleOwnership( + from: effects.runtimeObjectsToUnregister + ) + } + +} + +extension WebInspectorModelContext { + func apply( + _ event: Runtime.Event, + targetID: WebInspectorTarget.ID? = nil, + isCurrentPageTarget: Bool = true + ) { + runtimeState.apply( + event, + sourceTargetID: targetID, + isCurrentPageTarget: isCurrentPageTarget + ) + } +} diff --git a/Sources/WebInspectorDataKit/WebInspectorPersistentModel.swift b/Sources/WebInspectorDataKit/WebInspectorPersistentModel.swift index a185669d..5e868736 100644 --- a/Sources/WebInspectorDataKit/WebInspectorPersistentModel.swift +++ b/Sources/WebInspectorDataKit/WebInspectorPersistentModel.swift @@ -3,7 +3,7 @@ import Observation /// Base protocol for identity-preserving observable DataKit models. public protocol WebInspectorPersistentModel: AnyObject, Observable, Hashable, Identifiable, SendableMetatype where ID: Hashable & Sendable { - /// Stable model identity within a ``WebInspectorContext``. + /// Stable model identity within a ``WebInspectorModelContext``. nonisolated var id: ID { get } } @@ -18,6 +18,3 @@ extension WebInspectorPersistentModel { hasher.combine(ObjectIdentifier(self)) } } - -/// Marker protocol for DataKit models that can be returned by fetch descriptors. -public protocol WebInspectorFetchableModel: WebInspectorPersistentModel {} diff --git a/Sources/WebInspectorDataKit/WebInspectorQueryRegistration.swift b/Sources/WebInspectorDataKit/WebInspectorQueryRegistration.swift new file mode 100644 index 00000000..94ddafde --- /dev/null +++ b/Sources/WebInspectorDataKit/WebInspectorQueryRegistration.swift @@ -0,0 +1,45 @@ +import Synchronization + +package struct WebInspectorQueryRegistrationID: Hashable, Sendable { + package var rawValue: UInt64 + + package init(rawValue: UInt64) { + self.rawValue = rawValue + } +} + +package final class WebInspectorQueryRegistrationLifetime: Sendable { + private let generation = Mutex(0) + + package init() {} + + package func nextGeneration() -> UInt64 { + generation.withLock { generation in + precondition( + generation < UInt64.max, + "Fetched-results query generation overflowed." + ) + generation += 1 + return generation + } + } + + package func isCurrent(generation expectedGeneration: UInt64) -> Bool { + generation.withLock { generation in + generation == expectedGeneration + } + } +} + +package struct WebInspectorIndexedQueryProjection: Sendable { + package var sourceEpoch: UInt64 + package var sequence: UInt64 + package var snapshot: WebInspectorFetchedResultsSnapshot + package var reconfigureItemIDs: Set +} + +package struct WebInspectorIndexedQueryDelivery: Sendable { + package var registrationID: WebInspectorQueryRegistrationID + package var generation: UInt64 + package var projection: WebInspectorIndexedQueryProjection +} diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift new file mode 100644 index 00000000..b8a44a93 --- /dev/null +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTestRuntime.swift @@ -0,0 +1,732 @@ +import Foundation +import WebInspectorDataKit +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +/// A ready-to-use DataKit model runtime backed by ProxyKit's production wire path. +/// +/// The runtime is confined to the actor that calls ``start(scenario:isolation:)``. +/// Call ``close()`` and await completion before releasing it. +public final class WebInspectorDataKitTestRuntime { + /// One DOM node in a test document. + public struct Node: Equatable, Sendable { + public let id: String + public let nodeType: Int + public let nodeName: String + public let localName: String + public let nodeValue: String + public let attributes: [String: String] + public let children: [Node] + + public init( + id: String, + nodeType: Int = 1, + nodeName: String, + localName: String = "", + nodeValue: String = "", + attributes: [String: String] = [:], + children: [Node] = [] + ) { + self.id = id + self.nodeType = nodeType + self.nodeName = nodeName + self.localName = localName + self.nodeValue = nodeValue + self.attributes = attributes + self.children = children + } + + /// Creates an element node whose protocol name is the uppercased local name. + public static func element( + id: String, + name: String, + attributes: [String: String] = [:], + children: [Node] = [] + ) -> Node { + Node( + id: id, + nodeName: name.uppercased(), + localName: name, + attributes: attributes, + children: children + ) + } + + /// Creates a text node. + public static func text(id: String, value: String) -> Node { + Node( + id: id, + nodeType: 3, + nodeName: "#text", + nodeValue: value + ) + } + } + + /// The initial or replacement DOM document supplied by a scenario. + public struct Document: Equatable, Sendable { + public let id: String + public let frameID: String + public let url: String? + public let children: [Node] + + public init( + id: String = "document", + frameID: String = "main-frame", + url: String? = nil, + children: [Node] = [] + ) { + self.id = id + self.frameID = frameID + self.url = url + self.children = children + } + } + + /// A complete Network request replayed during `Network.enable`. + public struct NetworkRequest: Sendable { + public let id: String + public let url: String + public let method: String + public let requestHeaders: [String: String] + public let status: Int + public let responseHeaders: [String: String] + public let mimeType: String + public let resourceType: Network.ResourceType + public let body: Network.Body? + + public init( + id: String, + url: String, + method: String = "GET", + requestHeaders: [String: String] = [:], + status: Int = 200, + responseHeaders: [String: String] = [:], + mimeType: String = "text/plain", + resourceType: Network.ResourceType = .fetch, + body: Network.Body? = nil + ) { + self.id = id + self.url = url + self.method = method + self.requestHeaders = requestHeaders + self.status = status + self.responseHeaders = responseHeaders + self.mimeType = mimeType + self.resourceType = resourceType + self.body = body + } + } + + /// The model bootstrap domain that should fail. + public enum AttachFailureDomain: Sendable { + case dom + case network + case console + case runtime + case css + } + + /// A deterministic model attachment failure. + public struct AttachFailure: Sendable { + public let domain: AttachFailureDomain + public let message: String + + public init(domain: AttachFailureDomain, message: String) { + self.domain = domain + self.message = message + } + } + + /// Inputs applied before the model reaches its first ready state. + public struct Scenario: Sendable { + public let configuration: WebInspectorModelContext.Configuration + public let document: Document + public let networkReplay: [NetworkRequest] + public let attachFailure: AttachFailure? + + public init( + configuration: WebInspectorModelContext.Configuration = .init(), + document: Document = .init(), + networkReplay: [NetworkRequest] = [], + attachFailure: AttachFailure? = nil + ) { + precondition( + Set(networkReplay.map(\.id)).count == networkReplay.count, + "A DataKit test scenario cannot replay duplicate Network request identifiers." + ) + self.configuration = configuration + self.document = document + self.networkReplay = networkReplay + self.attachFailure = attachFailure + } + } + + public enum RuntimeError: Error, Equatable, Sendable { + case closed + case modelFailed(WebInspectorModelContext.Failure) + case selectedNodeMissing(String) + } + + /// The ready, actor-confined DataKit model context. + public let model: WebInspectorModelContext + + private let proxyRuntime: WebInspectorProxyTestRuntime + private let driver: ScenarioDriver + private let driverTask: Task + private var isClosed: Bool + + /// Starts the production ProxyKit path and returns after DataKit is ready. + public static func start( + scenario: Scenario = .init(), + isolation: isolated (any Actor) = #isolation + ) async throws -> WebInspectorDataKitTestRuntime { + let proxyRuntime = try await WebInspectorProxyTestRuntime.start() + let driver = ScenarioDriver( + peer: proxyRuntime.peer, + document: scenario.document, + networkReplay: scenario.networkReplay, + attachFailure: scenario.attachFailure + ) + let driverTask = ScenarioDriver.makeConsumerTask( + commands: proxyRuntime.peer.commands, + driver: driver + ) + let model = WebInspectorModelContext(configuration: scenario.configuration) + + do { + try await model.attach(to: proxyRuntime.proxy, isolation: isolation) + return WebInspectorDataKitTestRuntime( + model: model, + proxyRuntime: proxyRuntime, + driver: driver, + driverTask: driverTask + ) + } catch { + await model.close() + await proxyRuntime.close() + driverTask.cancel() + await driverTask.value + throw error + } + } + + /// Emits a complete Network request on the current page. + public nonisolated(nonsending) func emitNetworkRequest( + _ request: NetworkRequest + ) async throws { + guard !isClosed else { + throw RuntimeError.closed + } + try await driver.emitNetworkRequest(request) + } + + /// Selects a document node through the real element-picker command path. + @discardableResult + public nonisolated(nonsending) func selectElementWithPicker( + nodeID: String, + remoteObjectID: String = "selected-test-node" + ) async throws -> WebInspectorDataKit.DOMNode { + guard !isClosed else { + throw RuntimeError.closed + } + try await driver.registerPickerSelection( + remoteObjectID: remoteObjectID, + nodeID: nodeID + ) + try await model.setElementPickerEnabled(true) + var updates = model.statusUpdates.makeAsyncIterator() + let expectedNodeID = WebInspectorDataKit.DOMNode.ID(DOM.Node.ID(nodeID)) + + do { + try await driver.emitPickerSelection(remoteObjectID: remoteObjectID) + } catch { + do { + try await model.setElementPickerEnabled(false) + } catch let cleanupError { + throw WebInspectorScopeError( + operationError: error, + cleanupError: cleanupError + ) + } + throw error + } + + while let status = await updates.next() { + switch status.state { + case .attached: + if !status.isElementPickerEnabled { + guard status.selectedNodeID == expectedNodeID else { + throw RuntimeError.selectedNodeMissing(nodeID) + } + guard let node = try model.selectedDOMNode else { + throw RuntimeError.selectedNodeMissing(nodeID) + } + return node + } + case let .failed(failure): + throw RuntimeError.modelFailed(failure) + case .closed: + throw RuntimeError.closed + case .detached, .attaching, .synchronizing, .detaching: + continue + } + } + throw RuntimeError.closed + } + + /// Commits a provisional page target and waits for the replacement model. + public nonisolated(nonsending) func replacePage( + with document: Document, + networkReplay: [NetworkRequest] = [] + ) async throws { + guard !isClosed else { + throw RuntimeError.closed + } + precondition( + Set(networkReplay.map(\.id)).count == networkReplay.count, + "A DataKit test scenario cannot replay duplicate Network request identifiers." + ) + let precedingGeneration = model.pageGeneration + var updates = model.statusUpdates.makeAsyncIterator() + let replacement = await driver.prepareReplacement( + document: document, + networkReplay: networkReplay + ) + + do { + try await proxyRuntime.peer.createTarget(.init( + id: replacement.newTargetID, + type: "page", + frameID: document.frameID, + isProvisional: true + )) + try await proxyRuntime.peer.commitProvisionalTarget( + from: replacement.oldTargetID, + to: replacement.newTargetID + ) + } catch { + await driver.rollbackReplacement(replacement) + throw error + } + + while await updates.next() != nil { + switch model.state { + case .attached: + if model.pageGeneration != precedingGeneration { + return + } + case let .failed(failure): + throw RuntimeError.modelFailed(failure) + case .closed: + throw RuntimeError.closed + case .detached, .attaching, .synchronizing, .detaching: + continue + } + } + throw RuntimeError.closed + } + + /// Closes the model, connection, and command consumer in ownership order. + public nonisolated(nonsending) func close() async { + guard !isClosed else { + return + } + isClosed = true + await model.close() + await proxyRuntime.close() + driverTask.cancel() + await driverTask.value + } + + private init( + model: WebInspectorModelContext, + proxyRuntime: WebInspectorProxyTestRuntime, + driver: ScenarioDriver, + driverTask: Task + ) { + self.model = model + self.proxyRuntime = proxyRuntime + self.driver = driver + self.driverTask = driverTask + isClosed = false + } + + deinit { + driverTask.cancel() + } +} + +private actor ScenarioDriver { + struct Replacement: Sendable { + let oldTargetID: String + let newTargetID: String + let oldDocument: WebInspectorDataKitTestRuntime.Document + let oldNetworkReplay: [WebInspectorDataKitTestRuntime.NetworkRequest] + } + + private let peer: WebInspectorTestPeer + private var currentTargetID: String + private var document: WebInspectorDataKitTestRuntime.Document + private var networkReplay: [WebInspectorDataKitTestRuntime.NetworkRequest] + private var responseBodies: [String: Network.Body] + private var attachFailure: WebInspectorDataKitTestRuntime.AttachFailure? + private var pickerSelections: [String: String] + private var replacementOrdinal: UInt64 + + init( + peer: WebInspectorTestPeer, + document: WebInspectorDataKitTestRuntime.Document, + networkReplay: [WebInspectorDataKitTestRuntime.NetworkRequest], + attachFailure: WebInspectorDataKitTestRuntime.AttachFailure? + ) { + self.peer = peer + currentTargetID = "page-main" + self.document = document + self.networkReplay = networkReplay + responseBodies = Dictionary( + uniqueKeysWithValues: networkReplay.compactMap { request in + request.body.map { (request.id, $0) } + } + ) + self.attachFailure = attachFailure + pickerSelections = [:] + replacementOrdinal = 0 + } + + nonisolated static func makeConsumerTask( + commands: WebInspectorTestPeer.Commands, + driver: ScenarioDriver + ) -> Task { + Task.detached { + while !Task.isCancelled { + do { + let command = try await commands.next() + try await driver.respond(to: command) + } catch is CancellationError { + return + } catch WebInspectorTestPeerError.connectionClosed { + return + } catch WebInspectorTestPeerError.staleCommand { + return + } catch { + await driver.terminateAfterUnexpectedFailure(error) + return + } + } + } + } + + func registerPickerSelection(remoteObjectID: String, nodeID: String) throws { + guard document.id == nodeID + || document.children.contains(where: { $0.containsNode(id: nodeID) }) else { + throw WebInspectorDataKitTestRuntime.RuntimeError.selectedNodeMissing(nodeID) + } + pickerSelections[remoteObjectID] = nodeID + } + + func emitPickerSelection(remoteObjectID: String) async throws { + try await peer.emitTargetEvent( + targetID: currentTargetID, + method: "Inspector.inspect", + parameters: try WebInspectorTestJSONObject(encoding: + InspectorInspectParameters( + object: .init( + type: "object", + subtype: "node", + objectId: remoteObjectID + ), + hints: [:] + ) + ) + ) + } + + func emitNetworkRequest( + _ request: WebInspectorDataKitTestRuntime.NetworkRequest + ) async throws { + responseBodies[request.id] = request.body + try await emitNetworkRequest(request, targetID: currentTargetID) + } + + func prepareReplacement( + document: WebInspectorDataKitTestRuntime.Document, + networkReplay: [WebInspectorDataKitTestRuntime.NetworkRequest] + ) -> Replacement { + replacementOrdinal &+= 1 + precondition(replacementOrdinal != 0, "DataKit test target ordinal exhausted.") + let replacement = Replacement( + oldTargetID: currentTargetID, + newTargetID: "page-replacement-\(replacementOrdinal)", + oldDocument: self.document, + oldNetworkReplay: self.networkReplay + ) + currentTargetID = replacement.newTargetID + self.document = document + self.networkReplay = networkReplay + responseBodies = Dictionary( + uniqueKeysWithValues: networkReplay.compactMap { request in + request.body.map { (request.id, $0) } + } + ) + return replacement + } + + func rollbackReplacement(_ replacement: Replacement) { + guard currentTargetID == replacement.newTargetID else { + return + } + currentTargetID = replacement.oldTargetID + document = replacement.oldDocument + networkReplay = replacement.oldNetworkReplay + responseBodies = Dictionary( + uniqueKeysWithValues: replacement.oldNetworkReplay.compactMap { request in + request.body.map { (request.id, $0) } + } + ) + } + + private func respond(to command: WebInspectorTestPeer.Command) async throws { + if let failure = matchingAttachFailure(for: command.method) { + attachFailure = nil + try await peer.fail(command, message: failure.message) + return + } + + switch command.method { + case "Page.enable", "CSS.enable", "Console.enable", "Runtime.enable", + "Page.disable", "CSS.disable", "Console.disable", "Runtime.disable", "Network.disable", + "Inspector.enable", "Inspector.initialized", "Inspector.disable", + "DOM.setInspectModeEnabled", "DOM.hideHighlight", + "Runtime.releaseObjectGroup": + try await peer.reply(to: command) + case "Network.enable": + guard case let .target(targetID) = command.destination else { + try await peer.fail(command, message: "Network.enable must target a page.") + return + } + for request in networkReplay { + try await emitNetworkRequest(request, targetID: targetID) + } + try await peer.reply(to: command) + case "DOM.getDocument": + try await peer.reply( + to: command, + with: try WebInspectorTestJSONObject(encoding: + DOMDocumentResult(root: DOMNodeWire(document: document)) + ) + ) + case "DOM.requestNode": + let parameters = try command.parameters.decode(RequestNodeParameters.self) + guard let nodeID = pickerSelections.removeValue(forKey: parameters.objectId) else { + try await peer.fail( + command, + message: "No DataKit test picker selection for \(parameters.objectId)." + ) + return + } + try await peer.reply( + to: command, + with: try WebInspectorTestJSONObject(encoding: NodeIDResult(nodeId: nodeID)) + ) + case "Network.getResponseBody": + let parameters = try command.parameters.decode(ResponseBodyParameters.self) + guard let body = responseBodies[parameters.requestId] else { + try await peer.fail( + command, + message: "No DataKit test response body for \(parameters.requestId)." + ) + return + } + try await peer.reply( + to: command, + with: try WebInspectorTestJSONObject(encoding: + ResponseBodyResult(body: body.data, base64Encoded: body.base64Encoded) + ) + ) + default: + try await peer.fail( + command, + message: "Unsupported DataKit test scenario command: \(command.method)." + ) + } + } + + private func matchingAttachFailure( + for method: String + ) -> WebInspectorDataKitTestRuntime.AttachFailure? { + guard let attachFailure else { + return nil + } + let expectedMethod: String + switch attachFailure.domain { + case .dom: expectedMethod = "DOM.getDocument" + case .network: expectedMethod = "Network.enable" + case .console: expectedMethod = "Console.enable" + case .runtime: expectedMethod = "Runtime.enable" + case .css: expectedMethod = "CSS.enable" + } + return method == expectedMethod ? attachFailure : nil + } + + private func emitNetworkRequest( + _ request: WebInspectorDataKitTestRuntime.NetworkRequest, + targetID: String + ) async throws { + try await peer.emitTargetEvent( + targetID: targetID, + method: "Network.requestWillBeSent", + parameters: try WebInspectorTestJSONObject(encoding: + RequestWillBeSentParameters( + requestId: request.id, + request: .init( + url: request.url, + method: request.method, + headers: request.requestHeaders + ), + timestamp: 1, + type: request.resourceType.rawValue + ) + ) + ) + try await peer.emitTargetEvent( + targetID: targetID, + method: "Network.responseReceived", + parameters: try WebInspectorTestJSONObject(encoding: + ResponseReceivedParameters( + requestId: request.id, + response: .init( + url: request.url, + status: request.status, + mimeType: request.mimeType, + headers: request.responseHeaders + ), + timestamp: 2, + type: request.resourceType.rawValue + ) + ) + ) + try await peer.emitTargetEvent( + targetID: targetID, + method: "Network.loadingFinished", + parameters: try WebInspectorTestJSONObject(encoding: + LoadingFinishedParameters(requestId: request.id, timestamp: 3) + ) + ) + } + + private func terminateAfterUnexpectedFailure(_ error: any Error) async { + await peer.failConnection( + with: "DataKit test scenario driver failed: \(error)" + ) + } +} + +private struct DOMDocumentResult: Encodable { + let root: DOMNodeWire +} + +private struct DOMNodeWire: Encodable { + let nodeId: String + let nodeType: Int + let nodeName: String + let localName: String + let nodeValue: String + let frameId: String? + let documentURL: String? + let attributes: [String] + let childNodeCount: Int + let children: [DOMNodeWire] + + init(document: WebInspectorDataKitTestRuntime.Document) { + nodeId = document.id + nodeType = 9 + nodeName = "#document" + localName = "" + nodeValue = "" + frameId = document.frameID + documentURL = document.url + attributes = [] + childNodeCount = document.children.count + children = document.children.map(DOMNodeWire.init(node:)) + } + + init(node: WebInspectorDataKitTestRuntime.Node) { + nodeId = node.id + nodeType = node.nodeType + nodeName = node.nodeName + localName = node.localName + nodeValue = node.nodeValue + frameId = nil + documentURL = nil + attributes = node.attributes.sorted { $0.key < $1.key }.flatMap { [$0.key, $0.value] } + childNodeCount = node.children.count + children = node.children.map(DOMNodeWire.init(node:)) + } +} + +private struct RequestWillBeSentParameters: Encodable { + struct Request: Encodable { + let url: String + let method: String + let headers: [String: String] + } + + let requestId: String + let request: Request + let timestamp: Double + let type: String +} + +private struct ResponseReceivedParameters: Encodable { + struct Response: Encodable { + let url: String + let status: Int + let mimeType: String + let headers: [String: String] + } + + let requestId: String + let response: Response + let timestamp: Double + let type: String +} + +private struct LoadingFinishedParameters: Encodable { + let requestId: String + let timestamp: Double +} + +private struct InspectorInspectParameters: Encodable { + struct RemoteObject: Encodable { + let type: String + let subtype: String + let objectId: String + } + + let object: RemoteObject + let hints: [String: String] +} + +private struct RequestNodeParameters: Decodable { + let objectId: String +} + +private struct NodeIDResult: Encodable { + let nodeId: String +} + +private struct ResponseBodyParameters: Decodable { + let requestId: String +} + +private struct ResponseBodyResult: Encodable { + let body: String + let base64Encoded: Bool +} + +private extension WebInspectorDataKitTestRuntime.Node { + func containsNode(id: String) -> Bool { + self.id == id || children.contains(where: { $0.containsNode(id: id) }) + } +} diff --git a/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTesting.docc/WebInspectorDataKitTesting.md b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTesting.docc/WebInspectorDataKitTesting.md new file mode 100644 index 00000000..c72c3a0c --- /dev/null +++ b/Sources/WebInspectorDataKitTesting/WebInspectorDataKitTesting.docc/WebInspectorDataKitTesting.md @@ -0,0 +1,50 @@ +# ``WebInspectorDataKitTesting`` + +Create ready DataKit model scenarios without scripting protocol startup. + +`WebInspectorDataKitTesting` drives the same raw peer and production connection +core as `WebInspectorProxyKitTesting`, then owns the model bootstrap replies and +resource teardown needed by a DataKit consumer test. + +```swift +import WebInspectorDataKitTesting + +let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + document: .init(children: [ + .element(id: "button", name: "button") + ]), + networkReplay: [ + .init( + id: "initial-request", + url: "https://example.test/" + ) + ] + ) +) + +let button = try await runtime.selectElementWithPicker(nodeID: "button") +precondition(button.localName == "button") + +await runtime.close() +``` + +The runtime and its model are non-`Sendable` and inherit the actor passed to +``WebInspectorDataKitTestRuntime/start(scenario:isolation:)``. Immutable fixture +values are `Sendable`. Always await ``WebInspectorDataKitTestRuntime/close()``; +deinitialization only cancels the command consumer as a synchronous backstop. + +## Topics + +### Runtime + +- ``WebInspectorDataKitTestRuntime`` +- ``WebInspectorDataKitTestRuntime/Scenario`` +- ``WebInspectorDataKitTestRuntime/close()`` + +### Fixtures + +- ``WebInspectorDataKitTestRuntime/Document`` +- ``WebInspectorDataKitTestRuntime/Node`` +- ``WebInspectorDataKitTestRuntime/NetworkRequest`` +- ``WebInspectorDataKitTestRuntime/AttachFailure`` diff --git a/Sources/WebInspectorKit/WebInspectorKit.swift b/Sources/WebInspectorKit/WebInspectorKit.swift index 50fd1c4e..4593101a 100644 --- a/Sources/WebInspectorKit/WebInspectorKit.swift +++ b/Sources/WebInspectorKit/WebInspectorKit.swift @@ -1 +1,2 @@ +@_exported import WebInspectorDataKit @_exported import WebInspectorUI diff --git a/Sources/WebInspectorProxyKit/CSS.swift b/Sources/WebInspectorProxyKit/CSS.swift index 73370ddc..924bc2f6 100644 --- a/Sources/WebInspectorProxyKit/CSS.swift +++ b/Sources/WebInspectorProxyKit/CSS.swift @@ -1,116 +1,98 @@ import Foundation -/// Types and commands for the Web Inspector CSS domain. -public enum CSS { - /// A target-scoped client for CSS commands and events. - public struct Client: Sendable { - package let context: DomainClientContext - - package init(context: DomainClientContext) { - self.context = context - } - - /// Enables CSS domain events and commands for the target. - public func enable() async throws { - try await context.dispatchVoid( - domain: .css, - method: "enable", - payload: EnablePayload() - ) - } - - /// Disables CSS domain events for the target. - public func disable() async throws { - try await context.dispatchVoid( - domain: .css, - method: "disable", - payload: DisablePayload() - ) - } - - /// Returns cascade information for the supplied DOM node. - public func matchedStyles(for node: DOM.Node.ID) async throws -> MatchedStyles { - try await context.dispatch( - domain: .css, - method: "getMatchedStylesForNode", - payload: GetMatchedStylesForNodePayload(node: node), - returning: MatchedStyles.self - ) - } - - /// Returns the computed CSS properties for the supplied DOM node. - public func computedStyle(for node: DOM.Node.ID) async throws -> [ComputedProperty] { - try await context.dispatch( - domain: .css, - method: "getComputedStyleForNode", - payload: GetComputedStyleForNodePayload(node: node), - returning: [ComputedProperty].self - ) - } - - /// Returns the inline and attribute-derived style declarations for the node. - public func inlineStyles(for node: DOM.Node.ID) async throws -> InlineStyles { - try await context.dispatch( - domain: .css, - method: "getInlineStylesForNode", - payload: GetInlineStylesForNodePayload(node: node), - returning: InlineStyles.self - ) - } - - /// Replaces the declaration text for a style and returns the updated style. - public func setStyleText(_ id: Style.ID, text: String) async throws -> Style { - try await context.dispatch( - domain: .css, - method: "setStyleText", - payload: SetStyleTextPayload(id: id, text: text), - returning: Style.self - ) - } - - /// Replaces the full text of a stylesheet. - public func setStyleSheetText(_ id: StyleSheet.ID, text: String) async throws { - try await context.dispatchVoid( - domain: .css, - method: "setStyleSheetText", - payload: SetStyleSheetTextPayload(id: id, text: text) - ) - } - - /// Replaces the selector text for a rule and returns the updated rule. - public func setRuleSelector(_ id: Rule.ID, selector: String) async throws -> Rule { - try await context.dispatch( - domain: .css, - method: "setRuleSelector", - payload: SetRuleSelectorPayload(id: id, selector: selector), - returning: Rule.self - ) - } - - /// Replaces the grouping header for a nested rule and returns the updated grouping. - public func setGroupingHeaderText(_ id: Rule.ID, text: String) async throws -> Rule.Grouping { - try await context.dispatch( - domain: .css, - method: "setGroupingHeaderText", - payload: SetGroupingHeaderTextPayload(id: id, text: text), - returning: Rule.Grouping.self - ) - } - - /// CSS domain events emitted by this target. - public var events: EventStream { - EventStream { - context.cssEvents() - } +/// A target-scoped handle for Web Inspector CSS commands and events. +public struct CSS: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.css + package static let eventDomain = WebInspectorProxyEventDomain.css + + package let endpoint: DomainEndpoint + + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } + + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .css(value) = event else { + return nil } + return value + } + + /// Runs an operation with an atomically registered CSS event scope. + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await _withEvents( + buffering: buffering, + isolation: isolation, + operation + ) + } + + /// Returns cascade information for the supplied DOM node. + public func matchedStyles(for node: DOM.Node.ID) async throws -> MatchedStyles { + try await dispatch( + method: "getMatchedStylesForNode", + payload: GetMatchedStylesForNodePayload(node: node), + returning: MatchedStyles.self + ) + } + + /// Returns the computed CSS properties for the supplied DOM node. + public func computedStyle(for node: DOM.Node.ID) async throws -> [ComputedProperty] { + try await dispatch( + method: "getComputedStyleForNode", + payload: GetComputedStyleForNodePayload(node: node), + returning: [ComputedProperty].self + ) + } + + /// Returns the inline and attribute-derived style declarations for the node. + public func inlineStyles(for node: DOM.Node.ID) async throws -> InlineStyles { + try await dispatch( + method: "getInlineStylesForNode", + payload: GetInlineStylesForNodePayload(node: node), + returning: InlineStyles.self + ) } - package struct EnablePayload: Sendable { - package init() {} + /// Replaces the declaration text for a style and returns the updated style. + public func setStyleText(_ id: Style.ID, text: String) async throws -> Style { + try await dispatch( + method: "setStyleText", + payload: SetStyleTextPayload(id: id, text: text), + returning: Style.self + ) } - package struct DisablePayload: Sendable { - package init() {} + /// Replaces the full text of a stylesheet. + public func setStyleSheetText(_ id: StyleSheet.ID, text: String) async throws { + try await dispatchVoid( + method: "setStyleSheetText", + payload: SetStyleSheetTextPayload(id: id, text: text) + ) + } + + /// Replaces the selector text for a rule and returns the updated rule. + public func setRuleSelector(_ id: Rule.ID, selector: String) async throws -> Rule { + try await dispatch( + method: "setRuleSelector", + payload: SetRuleSelectorPayload(id: id, selector: selector), + returning: Rule.self + ) + } + + /// Replaces the grouping header for a nested rule and returns the updated grouping. + public func setGroupingHeaderText(_ id: Rule.ID, text: String) async throws -> Rule.Grouping { + try await dispatch( + method: "setGroupingHeaderText", + payload: SetGroupingHeaderTextPayload(id: id, text: text), + returning: Rule.Grouping.self + ) } package struct GetMatchedStylesForNodePayload: Sendable { @@ -635,29 +617,6 @@ public enum CSS { case unknown(RawEvent) } - /// An asynchronous stream of CSS domain events. - public struct EventStream: AsyncSequence, Sendable { - /// The event yielded by the stream. - public typealias Element = Event - - /// The iterator type used by the stream. - public typealias AsyncIterator = AsyncStream.Iterator - - private let makeStream: @Sendable () -> AsyncStream - - package init( - _ makeStream: @escaping @Sendable () -> AsyncStream = { - finishedStream(of: Event.self) - } - ) { - self.makeStream = makeStream - } - - /// Creates an iterator over CSS events. - public func makeAsyncIterator() -> AsyncIterator { - makeStream().makeAsyncIterator() - } - } } package extension CSS.StyleSheet.ID { diff --git a/Sources/WebInspectorProxyKit/Console.swift b/Sources/WebInspectorProxyKit/Console.swift index f08980f5..b6cbb379 100644 --- a/Sources/WebInspectorProxyKit/Console.swift +++ b/Sources/WebInspectorProxyKit/Console.swift @@ -1,65 +1,52 @@ import Foundation -/// Types and commands for the Web Inspector Console domain. -public enum Console { - /// A target-scoped client for Console commands and events. - public struct Client: Sendable { - package let context: DomainClientContext - - package init(context: DomainClientContext) { - self.context = context - } +/// A target-scoped handle for Web Inspector Console commands and events. +public struct Console: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.console + package static let eventDomain = WebInspectorProxyEventDomain.console - /// Enables Console domain events and commands for the target. - public func enable() async throws { - try await context.dispatchVoid( - domain: .console, - method: "enable", - payload: EnablePayload() - ) - } + package let endpoint: DomainEndpoint - /// Disables Console domain events for the target. - public func disable() async throws { - try await context.dispatchVoid( - domain: .console, - method: "disable", - payload: DisablePayload() - ) - } - - /// Clears console messages in the inspected target. - public func clearMessages() async throws { - try await context.dispatchVoid( - domain: .console, - method: "clearMessages", - payload: ClearMessagesPayload() - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// Sets the logging level for a WebKit logging channel. - public func setLoggingChannelLevel(_ source: ChannelSource, level: ChannelLevel) async throws { - try await context.dispatchVoid( - domain: .console, - method: "setLoggingChannelLevel", - payload: SetLoggingChannelLevelPayload(source: source, level: level) - ) + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .console(value) = event else { + return nil } + return value.event + } - /// Console domain events emitted by this target. - public var events: EventStream { - EventStream { - context.consoleEvents() - } - } + /// Runs an operation with an atomically registered Console event scope. + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await _withEvents( + buffering: buffering, + isolation: isolation, + operation + ) } - package struct EnablePayload: Sendable { - package init() {} + /// Clears console messages in the inspected target. + public func clearMessages() async throws { + try await dispatchVoid( + method: "clearMessages", + payload: ClearMessagesPayload() + ) } - package struct DisablePayload: Sendable { - package init() {} + /// Sets the logging level for a WebKit logging channel. + public func setLoggingChannelLevel(_ source: ChannelSource, level: ChannelLevel) async throws { + try await dispatchVoid( + method: "setLoggingChannelLevel", + payload: SetLoggingChannelLevelPayload(source: source, level: level) + ) } package struct ClearMessagesPayload: Sendable { @@ -269,27 +256,4 @@ public enum Console { } } - /// An asynchronous stream of Console domain events. - public struct EventStream: AsyncSequence, Sendable { - /// The event yielded by the stream. - public typealias Element = Event - - /// The iterator type used by the stream. - public typealias AsyncIterator = AsyncStream.Iterator - - private let makeStream: @Sendable () -> AsyncStream - - package init( - _ makeStream: @escaping @Sendable () -> AsyncStream = { - finishedStream(of: Event.self) - } - ) { - self.makeStream = makeStream - } - - /// Creates an iterator over Console events. - public func makeAsyncIterator() -> AsyncIterator { - makeStream().makeAsyncIterator() - } - } } diff --git a/Sources/WebInspectorProxyKit/DOM.swift b/Sources/WebInspectorProxyKit/DOM.swift index 8e4b5ca9..5093eaba 100644 --- a/Sources/WebInspectorProxyKit/DOM.swift +++ b/Sources/WebInspectorProxyKit/DOM.swift @@ -1,7 +1,23 @@ import Foundation -/// Types and commands for the Web Inspector DOM domain. -public enum DOM { +/// A target-scoped handle for Web Inspector DOM commands and events. +public struct DOM: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.dom + package static let eventDomain = WebInspectorProxyEventDomain.dom + + package let endpoint: DomainEndpoint + + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } + + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .dom(value) = event else { + return nil + } + return value + } + /// A DOM element attribute. public struct Attribute: Hashable, Sendable { /// The attribute name. @@ -17,176 +33,238 @@ public enum DOM { } } - /// A target-scoped client for DOM commands and events. - public struct Client: Sendable { - package let context: DomainClientContext - - package init(context: DomainClientContext) { - self.context = context - } + /// Runs an operation with an atomically registered DOM event scope. + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await _withEvents( + buffering: buffering, + isolation: isolation, + operation + ) + } - /// Returns the root document node for the target. - public func getDocument() async throws -> Node { - try await context.dispatch( - domain: .dom, - method: "getDocument", - payload: GetDocumentPayload(), - returning: Node.self - ) - } + /// Returns the root document node for the target. + public func getDocument() async throws -> Node { + try await dispatch( + method: "getDocument", + payload: GetDocumentPayload(), + returning: Node.self + ) + } - /// Requests child-node events for a node up to the supplied depth. - public func requestChildNodes(_ id: Node.ID, depth: Int = 1) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "requestChildNodes", - payload: RequestChildNodesPayload(id: id, depth: depth) - ) - } + /// Requests child-node events for a node up to the supplied depth. + public func requestChildNodes(_ id: Node.ID, depth: Int = 1) async throws { + try await dispatchVoid( + method: "requestChildNodes", + payload: RequestChildNodesPayload(id: id, depth: depth) + ) + } - /// Resolves a runtime object into a DOM node identity. - public func requestNode(forRemoteObject objectID: Runtime.RemoteObject.ID) async throws -> Node.ID { - try await context.dispatch( - domain: .dom, - method: "requestNode", - payload: RequestNodePayload(objectID: objectID), - returning: Node.ID.self - ) - } + /// Resolves a runtime object through the current page DOM agent. + /// + /// WebKit does not implement this command for frame targets. The returned + /// identity therefore belongs to the unscoped current-page DOM namespace. + public func requestNode(forRemoteObject objectID: Runtime.RemoteObject.ID) async throws -> Node.ID { + try await dispatch( + method: "requestNode", + payload: RequestNodePayload(objectID: objectID), + returning: Node.ID.self + ) + } - /// Returns serialized outer HTML for a node. - public func outerHTML(of id: Node.ID) async throws -> String { - try await context.dispatch( - domain: .dom, - method: "getOuterHTML", - payload: GetOuterHTMLPayload(id: id), - returning: String.self - ) - } + /// Returns serialized outer HTML for a node. + public func outerHTML(of id: Node.ID) async throws -> String { + try await dispatch( + method: "getOuterHTML", + payload: GetOuterHTMLPayload(id: id), + returning: String.self + ) + } - /// Returns the current attributes for a node. - public func attributes(of id: Node.ID) async throws -> [Attribute] { - try await context.dispatch( - domain: .dom, - method: "getAttributes", - payload: GetAttributesPayload(id: id), - returning: [Attribute].self - ) - } + /// Returns the current attributes for a node. + public func attributes(of id: Node.ID) async throws -> [Attribute] { + try await dispatch( + method: "getAttributes", + payload: GetAttributesPayload(id: id), + returning: [Attribute].self + ) + } - /// Sets a single attribute value on a node. - public func setAttributeValue(_ id: Node.ID, name: String, value: String) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "setAttributeValue", - payload: SetAttributeValuePayload(id: id, name: name, value: value) - ) - } + /// Sets a single attribute value on a node. + public func setAttributeValue(_ id: Node.ID, name: String, value: String) async throws { + try await dispatchVoid( + method: "setAttributeValue", + payload: SetAttributeValuePayload(id: id, name: name, value: value) + ) + } - /// Replaces attributes on a node using raw attribute text. - public func setAttributesAsText(_ id: Node.ID, text: String, name: String? = nil) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "setAttributesAsText", - payload: SetAttributesAsTextPayload(id: id, text: text, name: name) - ) - } + /// Replaces attributes on a node using raw attribute text. + public func setAttributesAsText(_ id: Node.ID, text: String, name: String? = nil) async throws { + try await dispatchVoid( + method: "setAttributesAsText", + payload: SetAttributesAsTextPayload(id: id, text: text, name: name) + ) + } - /// Removes an attribute from a node. - public func removeAttribute(_ id: Node.ID, name: String) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "removeAttribute", - payload: RemoveAttributePayload(id: id, name: name) - ) - } + /// Removes an attribute from a node. + public func removeAttribute(_ id: Node.ID, name: String) async throws { + try await dispatchVoid( + method: "removeAttribute", + payload: RemoveAttributePayload(id: id, name: name) + ) + } - /// Replaces a node with the supplied outer HTML. - public func setOuterHTML(_ id: Node.ID, html: String) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "setOuterHTML", - payload: SetOuterHTMLPayload(id: id, html: html) - ) - } + /// Replaces a node with the supplied outer HTML. + public func setOuterHTML(_ id: Node.ID, html: String) async throws { + try await dispatchVoid( + method: "setOuterHTML", + payload: SetOuterHTMLPayload(id: id, html: html) + ) + } - /// Removes a node from the document. - public func removeNode(_ id: Node.ID) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "removeNode", - payload: RemoveNodePayload(id: id) - ) - } + /// Removes a node from the document. + public func removeNode(_ id: Node.ID) async throws { + try await dispatchVoid( + method: "removeNode", + payload: RemoveNodePayload(id: id) + ) + } - /// Marks the current DOM state as an undoable editing checkpoint. - public func markUndoableState() async throws { - try await context.dispatchVoid( - domain: .dom, - method: "markUndoableState", - payload: MarkUndoableStatePayload() - ) - } + /// Marks the current DOM state as an undoable editing checkpoint. + public func markUndoableState() async throws { + try await dispatchVoid( + method: "markUndoableState", + payload: MarkUndoableStatePayload() + ) + } - /// Highlights a DOM node in the inspected page. - public func highlightNode(_ id: Node.ID) async throws { - // WebKit cannot highlight frame-owned DOM nodes from frame targets - // yet; its frontend intentionally no-ops these nodes instead of - // routing a scoped id to the wrong page node. - guard id.targetScopeRawValue == nil else { - return - } - try await context.dispatchVoid( - domain: .dom, - method: "highlightNode", - payload: HighlightNodePayload(id: id) - ) - } + /// Highlights a DOM node in the inspected page. + public func highlightNode(_ id: Node.ID) async throws { + // WebKit cannot highlight frame-owned DOM nodes from frame targets + // yet; its frontend intentionally no-ops these nodes instead of + // routing a scoped id to the wrong page node. + guard id.targetScopeRawValue == nil else { + return + } + try await dispatchVoid( + method: "highlightNode", + payload: HighlightNodePayload(id: id) + ) + } - /// Clears the current DOM highlight. - public func hideHighlight() async throws { - try await context.dispatchVoid( - domain: .dom, - method: "hideHighlight", - payload: HideHighlightPayload() - ) - } + /// Clears the current DOM highlight. + public func hideHighlight() async throws { + try await dispatchVoid( + method: "hideHighlight", + payload: HideHighlightPayload() + ) + } - /// Enables or disables WebKit's element picker. - public func setInspectMode(enabled: Bool) async throws { - try await context.dispatchVoid( - domain: .dom, - method: "setInspectModeEnabled", - payload: SetInspectModeEnabledPayload(enabled: enabled) - ) - } + package func setInspectMode(enabled: Bool) async throws { + try await dispatchVoid( + method: "setInspectModeEnabled", + payload: SetInspectModeEnabledPayload(enabled: enabled) + ) + } - /// Undoes the most recent DOM edit recorded by WebKit. - public func undo() async throws { - try await context.dispatchVoid( - domain: .dom, - method: "undo", - payload: UndoPayload() + /// Runs an operation while WebKit's element picker is enabled. + /// + /// The Inspector capability and its event subscriber are installed before + /// inspect mode is enabled. The scope disables inspect mode and releases + /// the capability on success, failure, and cancellation. + public func withElementPicker( + buffering: WebInspectorEventBufferingPolicy = .bounded(16), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + let inspector = Inspector(endpoint: endpoint) + return try await inspector._withEvents( + buffering: buffering, + isolation: isolation + ) { inspectorEvents in + let pair = AsyncThrowingStream.makeStream( + of: WebInspectorPageEvent.self ) - } + let projectionTask = Task { + do { + for try await pageEvent in inspectorEvents { + switch pageEvent { + case let .reset(generation): + pair.continuation.yield(.reset(generation)) + case let .event(generation, event): + guard case let .inspect(object, _) = event, + object.subtype?.rawValue == "node", + let objectID = object.id else { + continue + } + let nodeID: Node.ID + do { + nodeID = try await requestNode( + forRemoteObject: objectID + ) + } catch WebInspectorProxyError.staleIdentifier { + continue + } catch WebInspectorProxyError.pageUnavailable { + continue + } + pair.continuation.yield( + .event(generation, nodeID) + ) + } + } + pair.continuation.finish() + } catch { + pair.continuation.finish(throwing: error) + } + } + pair.continuation.onTermination = { _ in + projectionTask.cancel() + } - /// Redoes the most recent DOM edit recorded by WebKit. - public func redo() async throws { - try await context.dispatchVoid( - domain: .dom, - method: "redo", - payload: RedoPayload() - ) - } + let operationResult: Result + do { + operationResult = .success( + try await operation(pair.stream) + ) + } catch { + operationResult = .failure(error) + } - /// DOM domain events emitted by this target. - public var events: EventStream { - EventStream { - context.domEvents() + projectionTask.cancel() + await projectionTask.value + pair.continuation.finish() + switch operationResult { + case let .success(output): + return output + case let .failure(error): + throw error } } } + /// Undoes the most recent DOM edit recorded by WebKit. + public func undo() async throws { + try await dispatchVoid( + method: "undo", + payload: UndoPayload() + ) + } + + /// Redoes the most recent DOM edit recorded by WebKit. + public func redo() async throws { + try await dispatchVoid( + method: "redo", + payload: RedoPayload() + ) + } + package struct GetDocumentPayload: Sendable { package init() {} } @@ -534,29 +612,6 @@ public enum DOM { case unknown(RawEvent) } - /// An asynchronous stream of DOM domain events. - public struct EventStream: AsyncSequence, Sendable { - /// The event yielded by the stream. - public typealias Element = Event - - /// The iterator type used by the stream. - public typealias AsyncIterator = AsyncStream.Iterator - - private let makeStream: @Sendable () -> AsyncStream - - package init( - _ makeStream: @escaping @Sendable () -> AsyncStream = { - finishedStream(of: Event.self) - } - ) { - self.makeStream = makeStream - } - - /// Creates an iterator over DOM events. - public func makeAsyncIterator() -> AsyncIterator { - makeStream().makeAsyncIterator() - } - } } package extension DOM.Node.ID { diff --git a/Sources/WebInspectorProxyKit/ElementPickerWire.swift b/Sources/WebInspectorProxyKit/ElementPickerWire.swift new file mode 100644 index 00000000..ce8568d8 --- /dev/null +++ b/Sources/WebInspectorProxyKit/ElementPickerWire.swift @@ -0,0 +1,49 @@ +import Foundation + +package func elementPickerModeParametersData( + enabled: Bool +) throws -> Data { + var object: [String: Any] = ["enabled": enabled] + if enabled { + object["highlightConfig"] = [ + "showInfo": false, + "contentColor": elementPickerHighlightColor( + red: 111, + green: 168, + blue: 220 + ), + "paddingColor": elementPickerHighlightColor( + red: 147, + green: 196, + blue: 125 + ), + "borderColor": elementPickerHighlightColor( + red: 255, + green: 229, + blue: 153 + ), + "marginColor": elementPickerHighlightColor( + red: 246, + green: 178, + blue: 107 + ), + ] + } + guard JSONSerialization.isValidJSONObject(object) else { + throw TransportSession.Error.malformedMessage + } + return try JSONSerialization.data(withJSONObject: object) +} + +private func elementPickerHighlightColor( + red: Int, + green: Int, + blue: Int +) -> [String: Any] { + [ + "r": red, + "g": green, + "b": blue, + "a": 0.66, + ] +} diff --git a/Sources/WebInspectorProxyKit/EventStreams.swift b/Sources/WebInspectorProxyKit/EventStreams.swift deleted file mode 100644 index 7ed36353..00000000 --- a/Sources/WebInspectorProxyKit/EventStreams.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -package func finishedStream(of type: Element.Type = Element.self) -> AsyncStream { - AsyncStream { continuation in - continuation.finish() - } -} diff --git a/Sources/WebInspectorProxyKit/Inspector.swift b/Sources/WebInspectorProxyKit/Inspector.swift index 49675a6f..9a921dab 100644 --- a/Sources/WebInspectorProxyKit/Inspector.swift +++ b/Sources/WebInspectorProxyKit/Inspector.swift @@ -1,36 +1,43 @@ import Foundation -package enum Inspector { - package struct Client: Sendable { - private let context: DomainClientContext +package struct Inspector: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.inspector + package static let eventDomain = WebInspectorProxyEventDomain.inspector - package init(context: DomainClientContext) { - self.context = context - } + package let endpoint: DomainEndpoint - package func enable() async throws { - try await context.dispatchVoid( - domain: .inspector, - method: "enable", - payload: EnablePayload() - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - package func disable() async throws { - try await context.dispatchVoid( - domain: .inspector, - method: "disable", - payload: DisablePayload() - ) + package static func extractEvent( + _ event: WebInspectorProxyEvent + ) -> Event? { + guard case let .inspector(value) = event else { + return nil } + return value + } - package func initialized() async throws { - try await context.dispatchVoid( - domain: .inspector, - method: "initialized", - payload: InitializedPayload() - ) - } + package func enable() async throws { + try await dispatchVoid( + method: "enable", + payload: EnablePayload() + ) + } + + package func disable() async throws { + try await dispatchVoid( + method: "disable", + payload: DisablePayload() + ) + } + + package func initialized() async throws { + try await dispatchVoid( + method: "initialized", + payload: InitializedPayload() + ) } package struct EnablePayload: Sendable { @@ -45,18 +52,8 @@ package enum Inspector { package init() {} } - package struct EventOrigin: Sendable { - package let targetID: WebInspectorTarget.ID - package let route: RoutingTargetID - - package init(targetID: WebInspectorTarget.ID, route: RoutingTargetID) { - self.targetID = targetID - self.route = route - } - } - package enum Event: Sendable { - case inspect(Runtime.RemoteObject, hints: Runtime.JSONValue?, origin: EventOrigin?) + case inspect(Runtime.RemoteObject, hints: Runtime.JSONValue?) case unknown(RawEvent) } } diff --git a/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift index bf8fe70a..2de509c8 100644 --- a/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift +++ b/Sources/WebInspectorProxyKit/LiveProxyEventDecoder.swift @@ -38,12 +38,12 @@ enum LiveProxyEventDecoder { ) throws -> WebInspectorTargetLifecycleEvent { switch event.method { case "Target.didCommitProvisionalTarget": - let params = try decode(TargetCommittedParams.self, from: event) + _ = try decode(TargetCommittedParams.self, from: event) guard let lifecycleTarget = target else { return .unknown(rawEvent(from: event)) } return .didCommitProvisionalTarget(WebInspectorTargetCommitLifecycle( - oldTargetID: params.oldTargetId.map { _ in targetID }, + oldTargetID: targetID, newTarget: lifecycleTarget )) case "Target.targetDestroyed": @@ -130,13 +130,7 @@ enum LiveProxyEventDecoder { switch event.method { case "Inspector.inspect": let params = try decode(InspectorInspectParams.self, from: event) - let origin = event.targetID.map { - Inspector.EventOrigin( - targetID: WebInspectorTarget.ID($0.rawValue), - route: RoutingTargetID($0.rawValue) - ) - } - return .inspect(params.object.proxyObject, hints: params.hints?.proxyValue, origin: origin) + return .inspect(params.object.proxyObject, hints: params.hints?.proxyValue) default: return .unknown(rawEvent(from: event)) } @@ -288,7 +282,7 @@ enum LiveProxyEventDecoder { let params = try decode(ExecutionContextDestroyedParams.self, from: event) return .executionContextDestroyed(Runtime.ExecutionContext.ID(params.executionContextId)) case "Runtime.executionContextsCleared": - return .executionContextsCleared(target: targetID) + return .executionContextsCleared default: return .unknown(rawEvent(from: event)) } @@ -308,7 +302,7 @@ enum LiveProxyEventDecoder { } private struct TargetCommittedParams: Decodable { - var oldTargetId: String? + var oldTargetId: String var newTargetId: String } diff --git a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift index 52c9a4c4..b53ce8a1 100644 --- a/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/LiveWebInspectorProxyBackend.swift @@ -2,11 +2,9 @@ import Foundation package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { private let transport: TransportSession - private let eventSubscriptions: LiveProxyEventSubscriptions package init(transport: TransportSession) { self.transport = transport - eventSubscriptions = LiveProxyEventSubscriptions() } package func dispatchCommand( @@ -19,7 +17,7 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } catch { throw mapTransportError(error, domain: command.domain.rawValue, method: command.method) } - let targetScopeRawValue = await targetScopeRawValue(for: command) + let targetScopeRawValue = await targetScopeRawValue(for: command.route) return try LiveProxyCommandDecoder.decode( Result.self, for: command, @@ -28,72 +26,24 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { ) } - package func waitForEventSubscription( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) async { - await eventSubscriptions.waitForActiveSubscriber( - LiveProxyEventSubscriptionKey(route: route, targetID: targetID, domain: domain) - ) - } - - package func waitForEventSubscriptions( + package func acquireEventScope( route: RoutingTargetID, targetID: WebInspectorTarget.ID, domain: WebInspectorProxyEventDomain, - minimumCount: Int - ) async { - await eventSubscriptions.waitForActiveSubscribers( - LiveProxyEventSubscriptionKey(route: route, targetID: targetID, domain: domain), - minimumCount: minimumCount + buffering: WebInspectorEventBufferingPolicy, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) async throws -> WebInspectorProxyEventScope { + try await transport.acquireEventScope( + route: route, + targetID: targetID, + domain: domain, + buffering: buffering, + extract: extract ) } - package nonisolated func events( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) -> AsyncStream { - AsyncStream { continuation in - let key = LiveProxyEventSubscriptionKey(route: route, targetID: targetID, domain: domain) - let subscriptionID = LiveProxyEventSubscriptionID() - let task = Task { - let stream = await transport.events(for: protocolDomain(for: domain)) - guard Task.isCancelled == false else { - continuation.finish() - return - } - await eventSubscriptions.register(key, id: subscriptionID) - for await event in stream { - guard Task.isCancelled == false else { - break - } - guard await shouldDeliver(event, to: route) else { - continue - } - do { - let lifecycleTarget = await lifecycleTarget(for: event, route: route, targetID: targetID) - let proxyEvent = try LiveProxyEventDecoder.proxyEvent( - from: event, - targetID: targetID, - lifecycleTarget: lifecycleTarget - ) - continuation.yield(await projectedEvent(proxyEvent, from: event, route: route)) - } catch { - preconditionFailure("Failed to decode \(event.method): \(error)") - } - } - await eventSubscriptions.unregister(key, id: subscriptionID) - continuation.finish() - } - continuation.onTermination = { _ in - task.cancel() - Task { - await eventSubscriptions.unregister(key, id: subscriptionID) - } - } - } + package func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws { + try await transport.releaseEventScope(id) } private func mapTransportError(_ error: any Error, domain: String, method: String) -> any Error { @@ -103,9 +53,11 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { switch transportError { case .transportClosed: return WebInspectorProxyError.closed - case let .replyTimeout(method, _): + case let .transportFailure(message): + return WebInspectorProxyError.disconnected(message) + case .replyTimeout: return WebInspectorProxyError.timeout(domain: domain, method: method) - case let .remoteError(method, _, message): + case let .remoteError(_, _, message): return WebInspectorProxyError.commandFailed(domain: domain, method: method, message: message) case .malformedMessage, .missingMainPageTarget, .missingTarget: return WebInspectorProxyError.commandFailed( @@ -116,13 +68,10 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { } } - private nonisolated func targetScopeRawValue( - for command: WebInspectorProxyCommand + private nonisolated func targetScopeRawValue( + for route: RoutingTargetID ) async -> String? { - if let resultTargetScopeRawValue = command.resultTargetScopeRawValue { - return resultTargetScopeRawValue - } - guard case let .target(rawValue) = command.route.storage else { + guard case let .target(rawValue) = route.storage else { return nil } let targetID = ProtocolTarget.ID(rawValue) @@ -138,697 +87,6 @@ package struct LiveWebInspectorProxyBackend: WebInspectorProxyBackend { return rawValue } - private nonisolated func shouldDeliver(_ event: ProtocolEvent, to route: RoutingTargetID) async -> Bool { - switch route.storage { - case let .target(rawValue): - if let targetID = event.targetID { - return targetID.rawValue == rawValue - } - let snapshot = await transport.snapshot() - return snapshot.currentMainPageTargetID?.rawValue == rawValue - case .currentPage: - let snapshot = await transport.snapshot() - if event.domain == .target, - event.method == "Target.targetDestroyed" { - // The registry has already dropped the destroyed record, so - // route by the event-time fact: only the destruction of the - // then-current main page belongs to the semantic page route. - return event.destroyedCurrentMainPageTarget - } - guard let currentMainPageTargetID = snapshot.currentMainPageTargetID else { - return false - } - guard let targetID = event.targetID else { - return true - } - if targetID == currentMainPageTargetID { - return true - } - guard let record = snapshot.targetsByID[targetID] else { - return false - } - // WebKit reports subframe picker and request activity on frame - // targets while WebInspectorKit exposes a semantic current page. - switch event.domain { - case .dom: - guard event.method != "DOM.documentUpdated" else { - return false - } - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .inspector: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .network: - // WebKit's page/ProxyingNetworkAgent owns process-wide - // Network.enable. This branch only projects target-wrapped - // frame Network events if WebKit emits them. - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .css: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .console: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - case .runtime: - return isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) - default: - return false - } - } - } - - private nonisolated func isCurrentPageFrameTarget( - _ record: ProtocolTarget.Record, - in snapshot: TransportSession.Snapshot, - currentMainPageTargetID: ProtocolTarget.ID - ) -> Bool { - guard record.kind == .frame, - let mainFrameID = snapshot.targetsByID[currentMainPageTargetID]?.frameID else { - return false - } - - guard var parentFrameID = record.parentFrameID else { - // WebKit may omit parentFrameId for a cross-origin frame target even - // though its frameId differs from the current page's main frame. - // TransportTargetRegistry already classifies that target as .frame; - // the current-page route must preserve the same semantic boundary - // or picker, DOM, and Network events are silently filtered. - guard let frameID = record.frameID else { - return false - } - return frameID != mainFrameID - } - - var visitedFrameIDs = Set() - while visitedFrameIDs.insert(parentFrameID).inserted { - if parentFrameID == mainFrameID { - return true - } - guard let parentTargetID = snapshot.frameTargetIDsByFrameID[parentFrameID], - let parentRecord = snapshot.targetsByID[parentTargetID], - let nextParentFrameID = parentRecord.parentFrameID else { - return false - } - parentFrameID = nextParentFrameID - } - return false - } - - private nonisolated func projectedEvent( - _ proxyEvent: WebInspectorProxyEvent, - from event: ProtocolEvent, - route: RoutingTargetID - ) async -> WebInspectorProxyEvent { - let snapshot = await transport.snapshot() - let scopedProxyEvent = scopedAgentOwnedEvent(proxyEvent, from: event, route: route, snapshot: snapshot) - guard case .currentPage = route.storage, - let targetID = event.targetID else { - return scopedProxyEvent - } - if event.domain == .inspector, - targetID == snapshot.currentMainPageTargetID { - return mainPageInspectorEvent(scopedProxyEvent) - } - guard let currentMainPageTargetID = snapshot.currentMainPageTargetID, - targetID != currentMainPageTargetID, - let record = snapshot.targetsByID[targetID], - isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) else { - return scopedProxyEvent - } - switch scopedProxyEvent { - case let .dom(domEvent): - return .dom(scopedDOMEvent(domEvent, targetRawValue: targetID.rawValue)) - case let .css(cssEvent): - return .css(scopedCSSEvent(cssEvent, targetRawValue: targetID.rawValue)) - case let .network(networkEvent): - return .network(scopedNetworkEvent(networkEvent, targetRawValue: targetID.rawValue)) - default: - return scopedProxyEvent - } - } - - private nonisolated func scopedAgentOwnedEvent( - _ proxyEvent: WebInspectorProxyEvent, - from event: ProtocolEvent, - route: RoutingTargetID, - snapshot: TransportSession.Snapshot - ) -> WebInspectorProxyEvent { - let targetScopeRawValue = runtimeAgentScopeRawValue(for: event, route: route, snapshot: snapshot) - switch proxyEvent { - case let .runtime(runtimeEvent): - return .runtime(scopedRuntimeEvent(runtimeEvent, targetScopeRawValue: targetScopeRawValue)) - case let .console(targetedEvent): - return .console(Console.TargetedEvent( - event: scopedConsoleEvent(targetedEvent.event, targetScopeRawValue: targetScopeRawValue), - targetID: targetedEvent.targetID - )) - case .targetLifecycle, .dom, .inspector, .css, .network: - return proxyEvent - } - } - - private nonisolated func runtimeAgentScopeRawValue( - for event: ProtocolEvent, - route: RoutingTargetID, - snapshot: TransportSession.Snapshot - ) -> String? { - let agentTargetID = event.sourceTargetID ?? event.targetID - guard let agentTargetID else { - return nil - } - if agentTargetID == snapshot.currentMainPageTargetID { - return nil - } - if let record = snapshot.targetsByID[agentTargetID], - record.kind == .page, - record.parentFrameID == nil { - return nil - } - return agentTargetID.rawValue - } - - private nonisolated func mainPageInspectorEvent( - _ proxyEvent: WebInspectorProxyEvent - ) -> WebInspectorProxyEvent { - guard case let .inspector(event) = proxyEvent, - case let .inspect(object, hints, _) = event else { - return proxyEvent - } - return .inspector(.inspect(object, hints: hints, origin: nil)) - } - - private nonisolated func scopedDOMEvent( - _ event: DOM.Event, - targetRawValue: String - ) -> DOM.Event { - switch event { - case .documentUpdated: - .documentUpdated - case let .setChildNodes(parent, nodes): - .setChildNodes( - parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), - nodes: nodes.map { scopedDOMNode($0, targetRawValue: targetRawValue) } - ) - case let .detachedRoot(node): - .detachedRoot(scopedDOMNode(node, targetRawValue: targetRawValue)) - case let .childNodeInserted(parent, previous, node): - .childNodeInserted( - parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), - previous: previous.map { scopedDOMNodeID($0, targetRawValue: targetRawValue) }, - node: scopedDOMNode(node, targetRawValue: targetRawValue) - ) - case let .childNodeRemoved(parent, node): - .childNodeRemoved( - parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), - node: scopedDOMNodeID(node, targetRawValue: targetRawValue) - ) - case let .childNodeCountUpdated(node, count): - .childNodeCountUpdated(scopedDOMNodeID(node, targetRawValue: targetRawValue), count: count) - case let .attributeModified(node, name, value): - .attributeModified(scopedDOMNodeID(node, targetRawValue: targetRawValue), name: name, value: value) - case let .attributeRemoved(node, name): - .attributeRemoved(scopedDOMNodeID(node, targetRawValue: targetRawValue), name: name) - case let .inlineStyleInvalidated(nodes): - .inlineStyleInvalidated(nodes.map { scopedDOMNodeID($0, targetRawValue: targetRawValue) }) - case let .characterDataModified(node, value): - .characterDataModified(scopedDOMNodeID(node, targetRawValue: targetRawValue), value: value) - case let .shadowRootPushed(host, root): - .shadowRootPushed( - host: scopedDOMNodeID(host, targetRawValue: targetRawValue), - root: scopedDOMNode(root, targetRawValue: targetRawValue) - ) - case let .shadowRootPopped(host, root): - .shadowRootPopped( - host: scopedDOMNodeID(host, targetRawValue: targetRawValue), - root: scopedDOMNodeID(root, targetRawValue: targetRawValue) - ) - case let .pseudoElementAdded(parent, element): - .pseudoElementAdded( - parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), - element: scopedDOMNode(element, targetRawValue: targetRawValue) - ) - case let .pseudoElementRemoved(parent, element): - .pseudoElementRemoved( - parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), - element: scopedDOMNodeID(element, targetRawValue: targetRawValue) - ) - case let .willDestroyDOMNode(node): - .willDestroyDOMNode(scopedDOMNodeID(node, targetRawValue: targetRawValue)) - case let .inspect(node): - .inspect(scopedDOMNodeID(node, targetRawValue: targetRawValue)) - case let .unknown(rawEvent): - .unknown(rawEvent) - } - } - - private nonisolated func scopedDOMNode( - _ node: DOM.Node, - targetRawValue: String - ) -> DOM.Node { - DOM.Node( - id: scopedDOMNodeID(node.id, targetRawValue: targetRawValue), - nodeType: node.nodeType, - nodeName: node.nodeName, - localName: node.localName, - nodeValue: node.nodeValue, - frameID: node.frameID, - documentURL: node.documentURL, - baseURL: node.baseURL, - attributes: node.attributes, - attributeList: node.attributeList, - childNodeCount: node.childNodeCount, - children: node.children?.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - contentDocument: node.contentDocument.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - shadowRoots: node.shadowRoots.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - templateContent: node.templateContent.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - beforePseudoElement: node.beforePseudoElement.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - otherPseudoElements: node.otherPseudoElements.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - afterPseudoElement: node.afterPseudoElement.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, - pseudoType: node.pseudoType, - shadowRootType: node.shadowRootType - ) - } - - private nonisolated func scopedDOMNodeID( - _ id: DOM.Node.ID, - targetRawValue: String - ) -> DOM.Node.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return DOM.Node.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) - } - - private nonisolated func scopedCSSEvent( - _ event: CSS.Event, - targetRawValue: String - ) -> CSS.Event { - switch event { - case let .styleSheetChanged(id): - .styleSheetChanged(scopedStyleSheetID(id, targetRawValue: targetRawValue)) - case let .styleSheetAdded(header): - .styleSheetAdded(CSS.StyleSheetHeader( - styleSheetID: scopedStyleSheetID(header.styleSheetID, targetRawValue: targetRawValue), - frameID: header.frameID, - sourceURL: header.sourceURL, - origin: header.origin, - title: header.title, - disabled: header.disabled, - isInline: header.isInline, - startLine: header.startLine, - startColumn: header.startColumn - )) - case let .styleSheetRemoved(id): - .styleSheetRemoved(scopedStyleSheetID(id, targetRawValue: targetRawValue)) - case .mediaQueryResultChanged: - .mediaQueryResultChanged - case let .nodeLayoutFlagsChanged(id): - .nodeLayoutFlagsChanged(scopedDOMNodeID(id, targetRawValue: targetRawValue)) - case let .unknown(rawEvent): - .unknown(rawEvent) - } - } - - private nonisolated func scopedStyleSheetID( - _ id: CSS.StyleSheet.ID, - targetRawValue: String - ) -> CSS.StyleSheet.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return CSS.StyleSheet.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) - } - - private nonisolated func scopedRuntimeEvent( - _ event: Runtime.Event, - targetScopeRawValue: String? - ) -> Runtime.Event { - guard let targetScopeRawValue else { - return event - } - switch event { - case let .executionContextCreated(context): - return .executionContextCreated(Runtime.ExecutionContext( - id: scopedExecutionContextID(context.id, targetRawValue: targetScopeRawValue), - name: context.name, - frameID: context.frameID, - kind: context.kind - )) - case let .executionContextDestroyed(id): - return .executionContextDestroyed(scopedExecutionContextID(id, targetRawValue: targetScopeRawValue)) - case .executionContextsCleared: - return .executionContextsCleared(target: WebInspectorTarget.ID(targetScopeRawValue)) - case let .unknown(rawEvent): - return .unknown(rawEvent) - } - } - - private nonisolated func scopedExecutionContextID( - _ id: Runtime.ExecutionContext.ID, - targetRawValue: String - ) -> Runtime.ExecutionContext.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return Runtime.ExecutionContext.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) - } - - private nonisolated func scopedConsoleEvent( - _ event: Console.Event, - targetScopeRawValue: String? - ) -> Console.Event { - guard let targetScopeRawValue else { - return event - } - switch event { - case let .messageAdded(message): - return .messageAdded(scopedConsoleMessage(message, targetRawValue: targetScopeRawValue)) - case let .messageRepeatCountUpdated(count, timestamp): - return .messageRepeatCountUpdated(count: count, timestamp: timestamp) - case let .messagesCleared(reason): - return .messagesCleared(reason: reason) - case let .unknown(rawEvent): - return .unknown(rawEvent) - } - } - - private nonisolated func scopedConsoleMessage( - _ message: Console.Message, - targetRawValue: String - ) -> Console.Message { - Console.Message( - source: message.source, - level: message.level, - type: message.type, - text: message.text, - url: message.url, - line: message.line, - column: message.column, - repeatCount: message.repeatCount, - parameters: message.parameters.map { scopedRemoteObject($0, targetRawValue: targetRawValue) }, - stackTrace: message.stackTrace, - networkRequestID: message.networkRequestID.map { - scopedNetworkRequestID($0, targetRawValue: targetRawValue) - }, - timestamp: message.timestamp - ) - } - - private nonisolated func scopedRemoteObject( - _ object: Runtime.RemoteObject, - targetRawValue: String - ) -> Runtime.RemoteObject { - Runtime.RemoteObject( - id: object.id.map { scopedRemoteObjectID($0, targetRawValue: targetRawValue) }, - kind: object.kind, - subtype: object.subtype, - className: object.className, - description: object.description, - value: object.value, - size: object.size, - preview: object.preview - ) - } - - private nonisolated func scopedRemoteObjectID( - _ id: Runtime.RemoteObject.ID, - targetRawValue: String - ) -> Runtime.RemoteObject.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return Runtime.RemoteObject.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) - } - - private nonisolated func scopedNetworkEvent( - _ event: Network.Event, - targetRawValue: String - ) -> Network.Event { - switch event { - case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): - .requestWillBeSent( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - request: scopedNetworkRequest(request, targetRawValue: targetRawValue), - resourceType: resourceType, - redirectResponse: redirectResponse, - timestamp: timestamp - ) - case let .responseReceived(id, response, resourceType, timestamp): - .responseReceived( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - response: response, - resourceType: resourceType, - timestamp: timestamp - ) - case let .dataReceived(id, dataLength, encodedDataLength, timestamp): - .dataReceived( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - dataLength: dataLength, - encodedDataLength: encodedDataLength, - timestamp: timestamp - ) - case let .loadingFinished(id, timestamp, sourceMapURL, metrics): - .loadingFinished( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - timestamp: timestamp, - sourceMapURL: sourceMapURL, - metrics: metrics - ) - case let .loadingFailed(id, errorText, canceled, timestamp): - .loadingFailed( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - errorText: errorText, - canceled: canceled, - timestamp: timestamp - ) - case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): - .requestServedFromMemoryCache( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - response: response, - resourceType: resourceType, - timestamp: timestamp - ) - case let .webSocket(event): - .webSocket(scopedWebSocketEvent(event, targetRawValue: targetRawValue)) - case let .unknown(rawEvent): - .unknown(rawEvent) - } - } - - private nonisolated func scopedWebSocketEvent( - _ event: Network.WebSocketEvent, - targetRawValue: String - ) -> Network.WebSocketEvent { - switch event { - case let .created(id, url): - .created(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), url: url) - case let .handshakeRequest(id, request, timestamp): - .handshakeRequest( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - request: scopedNetworkRequest(request, targetRawValue: targetRawValue), - timestamp: timestamp - ) - case let .handshakeResponse(id, response, timestamp): - .handshakeResponse( - id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), - response: response, - timestamp: timestamp - ) - case let .closed(id, timestamp): - .closed(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), timestamp: timestamp) - case let .frameSent(id, frame, timestamp): - .frameSent(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), frame: frame, timestamp: timestamp) - case let .frameReceived(id, frame, timestamp): - .frameReceived(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), frame: frame, timestamp: timestamp) - case let .error(id, message, timestamp): - .error(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), message: message, timestamp: timestamp) - case let .other(rawEvent): - .other(rawEvent) - } - } - - private nonisolated func scopedNetworkRequest( - _ request: Network.Request, - targetRawValue: String - ) -> Network.Request { - Network.Request( - id: scopedNetworkRequestID(request.id, targetRawValue: targetRawValue), - url: request.url, - method: request.method, - headers: request.headers, - postData: request.postData, - referrerPolicy: request.referrerPolicy, - integrity: request.integrity, - backendResourceIdentifier: request.backendResourceIdentifier - ) - } - - private nonisolated func scopedNetworkRequestID( - _ id: Network.Request.ID, - targetRawValue: String - ) -> Network.Request.ID { - guard id.targetScopeRawValue == nil else { - return id - } - return Network.Request.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) - } - - private nonisolated func lifecycleTarget( - for event: ProtocolEvent, - route: RoutingTargetID, - targetID: WebInspectorTarget.ID - ) async -> WebInspectorLifecycleTarget? { - guard event.domain == .target, - event.method == "Target.didCommitProvisionalTarget", - let protocolTargetID = event.targetID else { - return nil - } - let snapshot = await transport.snapshot() - guard let record = snapshot.targetsByID[protocolTargetID] else { - return nil - } - return WebInspectorLifecycleTarget( - semanticID: semanticTargetID(for: route, targetID: targetID), - record: record - ) - } - - private nonisolated func semanticTargetID( - for route: RoutingTargetID, - targetID: WebInspectorTarget.ID - ) -> WebInspectorTarget.ID { - switch route.storage { - case .currentPage: - .currentPage - case .target: - targetID - } - } -} - -private func protocolDomain(for domain: WebInspectorProxyEventDomain) -> ProtocolDomain { - switch domain { - case .target: - .target - case .dom: - .dom - case .inspector: - .inspector - case .css: - .css - case .network: - .network - case .console: - .console - case .runtime: - .runtime - case .page: - .page - } -} - -private struct LiveProxyEventSubscriptionKey: Hashable, Sendable { - var route: RoutingTargetID - var targetID: WebInspectorTarget.ID - var domain: WebInspectorProxyEventDomain -} - -private struct LiveProxyEventSubscriptionID: Hashable, Sendable { - var rawValue = UUID() -} - -private actor LiveProxyEventSubscriptions { - private struct Waiter { - let id: UInt64 - let minimumCount: Int - let continuation: CheckedContinuation - } - - private var activeSubscriberIDs: [LiveProxyEventSubscriptionKey: Set] = [:] - private var waiters: [LiveProxyEventSubscriptionKey: [Waiter]] = [:] - private var nextWaiterID: UInt64 = 0 - private var cancelledWaiterIDs: Set = [] - - func register(_ key: LiveProxyEventSubscriptionKey, id: LiveProxyEventSubscriptionID) { - let inserted = activeSubscriberIDs[key, default: []].insert(id).inserted - guard inserted else { - return - } - guard let pending = waiters.removeValue(forKey: key) else { - return - } - let count = activeSubscriberIDs[key, default: []].count - var remaining: [Waiter] = [] - for waiter in pending { - if count >= waiter.minimumCount { - waiter.continuation.resume() - } else { - remaining.append(waiter) - } - } - if remaining.isEmpty == false { - waiters[key] = remaining - } - } - - func unregister(_ key: LiveProxyEventSubscriptionKey, id: LiveProxyEventSubscriptionID) { - guard var ids = activeSubscriberIDs[key], - ids.remove(id) != nil else { - return - } - if ids.isEmpty { - activeSubscriberIDs[key] = nil - } else { - activeSubscriberIDs[key] = ids - } - } - - func waitForActiveSubscriber(_ key: LiveProxyEventSubscriptionKey) async { - await waitForActiveSubscribers(key, minimumCount: 1) - } - - func waitForActiveSubscribers(_ key: LiveProxyEventSubscriptionKey, minimumCount: Int) async { - guard activeSubscriberIDs[key, default: []].count < minimumCount else { - return - } - let waiterID = nextWaiterID - nextWaiterID += 1 - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - addWaiter( - Waiter(id: waiterID, minimumCount: minimumCount, continuation: continuation), - key: key - ) - } - } onCancel: { - Task { - await self.cancelWaiter(waiterID, key: key) - } - } - cancelledWaiterIDs.remove(waiterID) - } - - private func addWaiter(_ waiter: Waiter, key: LiveProxyEventSubscriptionKey) { - guard cancelledWaiterIDs.remove(waiter.id) == nil else { - waiter.continuation.resume() - return - } - waiters[key, default: []].append(waiter) - } - - private func cancelWaiter(_ id: UInt64, key: LiveProxyEventSubscriptionKey) { - guard var pending = waiters[key], - let index = pending.firstIndex(where: { $0.id == id }) else { - cancelledWaiterIDs.insert(id) - return - } - let waiter = pending.remove(at: index) - if pending.isEmpty { - waiters[key] = nil - } else { - waiters[key] = pending - } - // Resuming lets the cancelled caller return and observe its own - // Task.isCancelled state instead of staying suspended forever. - waiter.continuation.resume() - } } private enum LiveProxyCommandEncoder { @@ -840,7 +98,8 @@ private enum LiveProxyCommandEncoder { domain: domain, method: "\(command.domain.rawValue).\(command.method)", routing: routing(for: command.route), - parametersData: try parametersData(for: command) + parametersData: try parametersData(for: command), + authority: command.authority ) } @@ -964,11 +223,7 @@ private enum LiveProxyCommandEncoder { case (.dom, "setInspectModeEnabled"): let payload = try payload(command.payload, as: DOM.SetInspectModeEnabledPayload.self, command: command) - var object: [String: Any] = ["enabled": payload.enabled] - if payload.enabled { - object["highlightConfig"] = highlightConfig() - } - return try data(object) + return try elementPickerModeParametersData(enabled: payload.enabled) case (.network, "getResponseBody"): let payload = try payload(command.payload, as: Network.GetResponseBodyPayload.self, command: command) @@ -995,6 +250,9 @@ private enum LiveProxyCommandEncoder { let rawValue = context.unscopedRawValue object["contextId"] = Int(rawValue) ?? rawValue } + if let objectGroup = payload.objectGroup { + object["objectGroup"] = Self.objectGroupRawValue(objectGroup) + } return try data(object) case (.runtime, "getProperties"): @@ -1189,8 +447,8 @@ private enum LiveProxyCommandDecoder { return () as! Result } if Result.self == DOM.Node.self { - let payload = try decode(DocumentResult.self, from: result.resultData) - return try payload.root.proxyNode() as! Result + let payload = try decode(ProtocolDOMDocumentResult.self, from: result.resultData) + return try payload.proxyRoot() as! Result } if Result.self == DOM.Node.ID.self { let payload = try decode(RequestNodeResult.self, from: result.resultData) @@ -1263,10 +521,6 @@ private enum LiveProxyCommandDecoder { try JSONDecoder().decode(type, from: data) } - private struct DocumentResult: Decodable { - var root: ProtocolDOMNodePayload - } - private struct RequestNodeResult: Decodable { var nodeId: String diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift index 5746e2db..32f01d56 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectablePage.swift @@ -1,56 +1,19 @@ +import Dispatch import WebKit import WebInspectorNativeBridge -package struct NativeInspectorConnection: Sendable { - package let transport: TransportSession - package let receiver: TransportReceiver - package let reloadPage: @MainActor @Sendable () async throws -> Void - package let canReloadPage: @MainActor @Sendable () -> Bool - private let cleanup: @MainActor @Sendable () -> Void - - package init( - transport: TransportSession, - receiver: TransportReceiver, - reloadPage: @escaping @MainActor @Sendable () async throws -> Void, - canReloadPage: @escaping @MainActor @Sendable () -> Bool, - cleanup: @escaping @MainActor @Sendable () -> Void - ) { - self.transport = transport - self.receiver = receiver - self.reloadPage = reloadPage - self.canReloadPage = canReloadPage - self.cleanup = cleanup - } - - package func close() async { - receiver.close() - await transport.detach() - await restoreInspectabilityIfNeeded() - } - - @MainActor - package func restoreInspectabilityIfNeeded() { - cleanup() - } -} - -package enum NativeInspectorConnectionFactory { +package enum NativeConnectionCoreFactory { @MainActor package static func attach( to webView: WKWebView, responseTimeout: Duration?, fatalFailureHandler: @escaping @Sendable (String) -> Void = { _ in } - ) async throws -> NativeInspectorConnection { + ) async throws -> ConnectionCore { let resolvedSymbols = try await NativeInspectorBackendFactory.resolvedSymbolsDetached() return try await attach( to: webView, resolvedSymbols: resolvedSymbols, - makeTransportSession: { backend in - TransportSession( - backend: backend, - responseTimeout: responseTimeout - ) - }, + responseTimeout: responseTimeout, fatalFailureHandler: fatalFailureHandler ) } @@ -59,12 +22,13 @@ package enum NativeInspectorConnectionFactory { package static func attach( to webView: WKWebView, resolvedSymbols: NativeInspectorResolvedSymbols, - makeTransportSession: @MainActor (any TransportBackend) -> TransportSession, + responseTimeout: Duration?, fatalFailureHandler: @escaping @Sendable (String) -> Void = { _ in } - ) async throws -> NativeInspectorConnection { + ) async throws -> ConnectionCore { let receiver = TransportReceiver() let page = NativeInspectablePage(webView: webView) - var transport: TransportSession? + var core: ConnectionCore? + var attachment: NativeAttachment? do { let backend = NativeInspectorBackendFactory.make( @@ -73,44 +37,116 @@ package enum NativeInspectorConnectionFactory { messageHandler: { message in receiver.receive(message) }, - fatalFailureHandler: fatalFailureHandler + fatalFailureHandler: { message in + fatalFailureHandler(message) + receiver.fail(message) + } + ) + let createdAttachment = NativeAttachment( + receiver: receiver, + backend: backend, + page: page + ) + attachment = createdAttachment + let createdCore = ConnectionCore( + backend: backend, + responseTimeout: responseTimeout, + closeAction: { + await createdAttachment.close() + } ) - let createdTransport = makeTransportSession(backend) - transport = createdTransport - receiver.setTransport(createdTransport) + core = createdCore + receiver.setCore(createdCore) try backend.attach() - - return NativeInspectorConnection( - transport: createdTransport, + try await awaitInitialTargetDiscovery( receiver: receiver, - reloadPage: { [page] in - try Task.checkCancellation() - try page.reload() - }, - canReloadPage: { [page] in - page.canReload - }, - cleanup: { [page] in - page.restoreInspectabilityIfNeeded() - } + core: createdCore ) + + return createdCore } catch { - receiver.close() - page.restoreInspectabilityIfNeeded() - await transport?.detach() + if let core { + await core.close() + } else if let attachment { + await attachment.close() + } else { + await receiver.close() + page.restoreInspectabilityIfNeeded() + } throw error } } + + /// Waits for the initial target messages queued by WebKit while attaching. + /// + /// `connectFrontend` synchronously enumerates targets but delivers its + /// frontend callbacks through the main queue. The queue barrier observes + /// the complete initial callback prefix. The receiver ordinal then waits + /// for exactly that prefix to finish mutating `ConnectionCore`; later live + /// messages do not extend this attachment barrier. + @MainActor + package static func awaitInitialTargetDiscovery( + receiver: TransportReceiver, + core: ConnectionCore + ) async throws { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + let initialTailOrdinal = receiver.tailOrdinal() + await receiver.waitUntilDrained(through: initialTailOrdinal) + try await core.requireOpen() + } +} + +@MainActor +package final class NativeAttachment { + private let receiver: TransportReceiver + private let backend: any NativeAttachmentBackend + private let page: NativeInspectablePage + private var isClosed = false + + package init( + receiver: TransportReceiver, + backend: any NativeAttachmentBackend, + page: NativeInspectablePage + ) { + self.receiver = receiver + self.backend = backend + self.page = page + } + + package func close() async { + guard !isClosed else { + return + } + isClosed = true + await receiver.close() + await backend.detach() + page.restoreInspectabilityIfNeeded() + } + + isolated deinit { + guard !isClosed else { + return + } + receiver.closeSynchronously() + backend.detachSynchronously() + page.restoreInspectabilityIfNeeded() + } } @MainActor package final class NativeInspectablePage { private weak var webView: WKWebView? + private let webViewIdentifier: ObjectIdentifier? private let inspectabilityOwner = NativeInspectabilityOwner() package init(webView: WKWebView) { self.webView = webView + webViewIdentifier = ObjectIdentifier(webView) NativeInspectabilityCoordinator.prepare( webView: webView, owner: inspectabilityOwner @@ -129,17 +165,23 @@ package final class NativeInspectablePage { } package func restoreInspectabilityIfNeeded() { - guard let webView else { + guard let webViewIdentifier else { return } NativeInspectabilityCoordinator.restoreIfOwned( - webView: webView, + webViewIdentifier: webViewIdentifier, owner: inspectabilityOwner ) } + isolated deinit { + restoreInspectabilityIfNeeded() + } + #if DEBUG - package init(missingWebViewForTesting: Void) {} + package init(missingWebViewForTesting: Void) { + webViewIdentifier = nil + } #endif } @@ -176,17 +218,19 @@ private enum NativeInspectabilityCoordinator { webView.isInspectable = true } - static func restoreIfOwned(webView: WKWebView, owner: NativeInspectabilityOwner) { - let key = ObjectIdentifier(webView) - guard let record = records[key] else { + static func restoreIfOwned( + webViewIdentifier: ObjectIdentifier, + owner: NativeInspectabilityOwner + ) { + guard let record = records[webViewIdentifier] else { return } record.owners.remove(ObjectIdentifier(owner)) guard record.owners.isEmpty else { return } - records[key] = nil - webView.isInspectable = record.originalInspectability + records[webViewIdentifier] = nil + record.webView?.isInspectable = record.originalInspectability } } diff --git a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift index 1d8777fc..8547c0ef 100644 --- a/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift +++ b/Sources/WebInspectorProxyKit/NativeAttachment/NativeInspectorBackend.swift @@ -2,9 +2,16 @@ import Foundation import WebKit import WebInspectorNativeBridge +/// The native attachment's actual variation boundary: asynchronous explicit +/// detach versus synchronous deinitialization backstop. Both production and +/// tests use the same `NativeAttachment` lifecycle implementation. +package protocol NativeAttachmentBackend: TransportBackend { + @MainActor func detachSynchronously() +} + @MainActor -package final class NativeInspectorBackend: TransportBackend { - private let webView: WKWebView +package final class NativeInspectorBackend: NativeAttachmentBackend { + private weak var webView: WKWebView? private let resolvedSymbols: NativeInspectorResolvedSymbols private nonisolated let messageHandler: @Sendable (String) -> Void private nonisolated let fatalFailureHandler: @Sendable (String) -> Void @@ -23,6 +30,9 @@ package final class NativeInspectorBackend: TransportBackend { } package func attach() throws { + guard let webView else { + throw NativeInspectablePageError.missingWebView + } let bridge = NativeInspectorBridge(webView: webView) bridge.messageHandler = { [messageHandler] message in messageHandler(message) @@ -45,8 +55,14 @@ package final class NativeInspectorBackend: TransportBackend { package nonisolated func detach() async { await MainActor.run { - bridge?.detach() - bridge = nil + detachSynchronously() } } + + package func detachSynchronously() { + bridge?.messageHandler = nil + bridge?.fatalFailureHandler = nil + bridge?.detach() + bridge = nil + } } diff --git a/Sources/WebInspectorProxyKit/Network.swift b/Sources/WebInspectorProxyKit/Network.swift index 939eec85..2e89266a 100644 --- a/Sources/WebInspectorProxyKit/Network.swift +++ b/Sources/WebInspectorProxyKit/Network.swift @@ -1,60 +1,51 @@ import Foundation -/// Types and commands for the Web Inspector Network domain. -public enum Network { - /// A target-scoped client for Network commands and events. - public struct Client: Sendable { - package let context: DomainClientContext - - package init(context: DomainClientContext) { - self.context = context - } +/// A target-scoped handle for Web Inspector Network commands and events. +public struct Network: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.network + package static let eventDomain = WebInspectorProxyEventDomain.network - /// Enables Network domain events and commands for the target. - public func enable() async throws { - try await context.dispatchVoid( - domain: .network, - method: "enable", - payload: EnablePayload() - ) - } + package let endpoint: DomainEndpoint - /// Disables Network domain events for the target. - public func disable() async throws { - try await context.dispatchVoid( - domain: .network, - method: "disable", - payload: DisablePayload() - ) - } - - /// Returns the response body for a completed network request. - public func responseBody( - for id: Request.ID, - backendResourceIdentifier: BackendResourceID? = nil - ) async throws -> Body { - try await context.dispatch( - domain: .network, - method: "getResponseBody", - payload: GetResponseBodyPayload(id: id, backendResourceIdentifier: backendResourceIdentifier), - returning: Body.self - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// Network domain events emitted by this target. - public var events: EventStream { - EventStream { - context.networkEvents() - } + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .network(value) = event else { + return nil } + return value } - package struct EnablePayload: Sendable { - package init() {} + /// Runs an operation with an atomically registered Network event scope. + /// + /// The first scope registers before `Network.enable` is sent. Scope + /// completion waits for the final matching `Network.disable`. + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await _withEvents( + buffering: buffering, + isolation: isolation, + operation + ) } - package struct DisablePayload: Sendable { - package init() {} + /// Returns the response body for a completed network request. + public func responseBody( + for id: Request.ID, + backendResourceIdentifier: BackendResourceID? = nil + ) async throws -> Body { + try await dispatch( + method: "getResponseBody", + payload: GetResponseBodyPayload(id: id, backendResourceIdentifier: backendResourceIdentifier), + returning: Body.self + ) } package struct GetResponseBodyPayload: Sendable { @@ -414,29 +405,6 @@ public enum Network { } } - /// An asynchronous stream of Network domain events. - public struct EventStream: AsyncSequence, Sendable { - /// The event yielded by the stream. - public typealias Element = Event - - /// The iterator type used by the stream. - public typealias AsyncIterator = AsyncStream.Iterator - - private let makeStream: @Sendable () -> AsyncStream - - package init( - _ makeStream: @escaping @Sendable () -> AsyncStream = { - finishedStream(of: Event.self) - } - ) { - self.makeStream = makeStream - } - - /// Creates an iterator over Network events. - public func makeAsyncIterator() -> AsyncIterator { - makeStream().makeAsyncIterator() - } - } } package extension Network.Request.ID { diff --git a/Sources/WebInspectorProxyKit/Page.swift b/Sources/WebInspectorProxyKit/Page.swift index 66ea1165..bfe1eb5f 100644 --- a/Sources/WebInspectorProxyKit/Page.swift +++ b/Sources/WebInspectorProxyKit/Page.swift @@ -1,23 +1,21 @@ import Foundation -/// Types and commands for the Web Inspector Page domain. -public enum Page { - /// A target-scoped client for Page commands. - public struct Client: Sendable { - package let context: DomainClientContext +/// A target-scoped handle for Web Inspector Page commands. +public struct Page: Sendable, WebInspectorDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.page - package init(context: DomainClientContext) { - self.context = context - } + package let endpoint: DomainEndpoint - /// Reloads the inspected page. - public func reload(ignoringCache: Bool = false) async throws { - try await context.dispatchVoid( - domain: .page, - method: "reload", - payload: ReloadPayload(ignoringCache: ignoringCache) - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } + + /// Reloads the inspected page. + public func reload(ignoringCache: Bool = false) async throws { + try await dispatchVoid( + method: "reload", + payload: ReloadPayload(ignoringCache: ignoringCache) + ) } package struct ReloadPayload: Sendable { diff --git a/Sources/WebInspectorProxyKit/ProtocolDOMNodePayload.swift b/Sources/WebInspectorProxyKit/ProtocolDOMNodePayload.swift index 03401ffe..9b6c17d7 100644 --- a/Sources/WebInspectorProxyKit/ProtocolDOMNodePayload.swift +++ b/Sources/WebInspectorProxyKit/ProtocolDOMNodePayload.swift @@ -1,5 +1,13 @@ import Foundation +struct ProtocolDOMDocumentResult: Decodable { + let root: ProtocolDOMNodePayload + + func proxyRoot() throws -> DOM.Node { + try root.proxyNode() + } +} + final class ProtocolDOMNodePayload: Decodable { var nodeId: String var nodeType: Int diff --git a/Sources/WebInspectorProxyKit/Runtime.swift b/Sources/WebInspectorProxyKit/Runtime.swift index 0f9ba6c4..a574f264 100644 --- a/Sources/WebInspectorProxyKit/Runtime.swift +++ b/Sources/WebInspectorProxyKit/Runtime.swift @@ -1,120 +1,114 @@ import Foundation -/// Types and commands for the Web Inspector Runtime domain. -public enum Runtime { - /// A target-scoped client for Runtime commands and events. - public struct Client: Sendable { - package let context: DomainClientContext +/// A target-scoped handle for Web Inspector Runtime commands and events. +public struct Runtime: Sendable, WebInspectorEventDomainHandle { + package static let commandDomain = WebInspectorProxyDomain.runtime + package static let eventDomain = WebInspectorProxyEventDomain.runtime - package init(context: DomainClientContext) { - self.context = context - } - - /// Enables Runtime domain events and commands for the target. - public func enable() async throws { - try await context.dispatchVoid( - domain: .runtime, - method: "enable", - payload: EnablePayload() - ) - } + package let endpoint: DomainEndpoint - /// Disables Runtime domain events for the target. - public func disable() async throws { - try await context.dispatchVoid( - domain: .runtime, - method: "disable", - payload: DisablePayload() - ) - } - - /// Evaluates a JavaScript expression in an execution context. - public func evaluate( - _ expression: String, - in context: ExecutionContext.ID? = nil - ) async throws -> EvaluationResult { - try await self.context.dispatch( - domain: .runtime, - method: "evaluate", - payload: EvaluatePayload(expression: expression, context: context), - returning: EvaluationResult.self - ) - } + package init(endpoint: DomainEndpoint) { + self.endpoint = endpoint + } - /// Returns property descriptors for a remote object. - public func properties( - of object: RemoteObject.ID, - ownProperties: Bool = true - ) async throws -> [PropertyDescriptor] { - try await context.dispatch( - domain: .runtime, - method: "getProperties", - payload: GetPropertiesPayload(object: object, ownProperties: ownProperties), - returning: [PropertyDescriptor].self - ) + package static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? { + guard case let .runtime(value) = event else { + return nil } + return value + } - /// Returns a compact preview for a remote object. - public func preview(of object: RemoteObject.ID) async throws -> ObjectPreview { - try await context.dispatch( - domain: .runtime, - method: "getPreview", - payload: GetPreviewPayload(object: object), - returning: ObjectPreview.self - ) - } + /// Runs an operation with an atomically registered Runtime event scope. + public func withEvents( + buffering: WebInspectorEventBufferingPolicy = .bounded(256), + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await _withEvents( + buffering: buffering, + isolation: isolation, + operation + ) + } - /// Returns entries for an array-like, map-like, or set-like remote object. - public func collectionEntries(of object: RemoteObject.ID) async throws -> [CollectionEntry] { - try await context.dispatch( - domain: .runtime, - method: "getCollectionEntries", - payload: GetCollectionEntriesPayload(object: object), - returning: [CollectionEntry].self - ) - } + /// Evaluates a JavaScript expression in an execution context. + public func evaluate( + _ expression: String, + in context: ExecutionContext.ID? = nil, + objectGroup: ObjectGroup? = nil + ) async throws -> EvaluationResult { + try await dispatch( + method: "evaluate", + payload: EvaluatePayload( + expression: expression, + context: context, + objectGroup: objectGroup + ), + returning: EvaluationResult.self + ) + } - /// Releases one remote object handle. - public func releaseObject(_ id: RemoteObject.ID) async throws { - try await context.dispatchVoid( - domain: .runtime, - method: "releaseObject", - payload: ReleaseObjectPayload(id: id) - ) - } + /// Returns property descriptors for a remote object. + public func properties( + of object: RemoteObject.ID, + ownProperties: Bool = true + ) async throws -> [PropertyDescriptor] { + try await dispatch( + method: "getProperties", + payload: GetPropertiesPayload(object: object, ownProperties: ownProperties), + returning: [PropertyDescriptor].self + ) + } - /// Releases all remote object handles in an object group. - public func releaseObjectGroup(_ group: ObjectGroup) async throws { - try await context.dispatchVoid( - domain: .runtime, - method: "releaseObjectGroup", - payload: ReleaseObjectGroupPayload(group: group) - ) - } + /// Returns a compact preview for a remote object. + public func preview(of object: RemoteObject.ID) async throws -> ObjectPreview { + try await dispatch( + method: "getPreview", + payload: GetPreviewPayload(object: object), + returning: ObjectPreview.self + ) + } - /// Runtime domain events emitted by this target. - public var events: EventStream { - EventStream { - context.runtimeEvents() - } - } + /// Returns entries for an array-like, map-like, or set-like remote object. + public func collectionEntries(of object: RemoteObject.ID) async throws -> [CollectionEntry] { + try await dispatch( + method: "getCollectionEntries", + payload: GetCollectionEntriesPayload(object: object), + returning: [CollectionEntry].self + ) } - package struct EnablePayload: Sendable { - package init() {} + /// Releases one remote object handle. + public func releaseObject(_ id: RemoteObject.ID) async throws { + try await dispatchVoid( + method: "releaseObject", + payload: ReleaseObjectPayload(id: id) + ) } - package struct DisablePayload: Sendable { - package init() {} + /// Releases all remote object handles in an object group. + public func releaseObjectGroup(_ group: ObjectGroup) async throws { + try await dispatchVoid( + method: "releaseObjectGroup", + payload: ReleaseObjectGroupPayload(group: group) + ) } package struct EvaluatePayload: Sendable { package let expression: String package let context: ExecutionContext.ID? + package let objectGroup: ObjectGroup? - package init(expression: String, context: ExecutionContext.ID?) { + package init( + expression: String, + context: ExecutionContext.ID?, + objectGroup: ObjectGroup? + ) { self.expression = expression self.context = context + self.objectGroup = objectGroup } } @@ -532,36 +526,13 @@ public enum Runtime { /// An execution context was destroyed. case executionContextDestroyed(ExecutionContext.ID) - /// Execution contexts were cleared for a target. - case executionContextsCleared(target: WebInspectorTarget.ID) + /// Execution contexts were cleared for this event scope. + case executionContextsCleared /// An event that is not modeled by this package. case unknown(RawEvent) } - /// An asynchronous stream of Runtime domain events. - public struct EventStream: AsyncSequence, Sendable { - /// The event yielded by the stream. - public typealias Element = Event - - /// The iterator type used by the stream. - public typealias AsyncIterator = AsyncStream.Iterator - - private let makeStream: @Sendable () -> AsyncStream - - package init( - _ makeStream: @escaping @Sendable () -> AsyncStream = { - finishedStream(of: Event.self) - } - ) { - self.makeStream = makeStream - } - - /// Creates an iterator over Runtime events. - public func makeAsyncIterator() -> AsyncIterator { - makeStream().makeAsyncIterator() - } - } } package extension Runtime.RemoteObject.ID { diff --git a/Sources/WebInspectorProxyKit/StructuredEventScopes.swift b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift new file mode 100644 index 00000000..cd46d72b --- /dev/null +++ b/Sources/WebInspectorProxyKit/StructuredEventScopes.swift @@ -0,0 +1,467 @@ +import Foundation +import Synchronization + +/// Controls how many protocol events a structured event scope may retain while +/// its consumer is suspended. +public enum WebInspectorEventBufferingPolicy: Equatable, Sendable { + /// Retains the oldest pending events up to `capacity`. + /// + /// `capacity` must be greater than zero. + /// + /// The first event that cannot be retained terminates only that subscriber + /// with ``WebInspectorProxyError/eventBufferOverflow(capacity:)``. + case bounded(Int) + + /// Retains every pending event. This is an explicit opt-in because protocol + /// domains do not provide a general resynchronization mechanism. + case unbounded + + package var capacity: Int? { + switch self { + case let .bounded(capacity): + precondition(capacity > 0, "A bounded Web Inspector event buffer must have a positive capacity.") + return capacity + case .unbounded: + return nil + } + } + +} + +package final class WebInspectorEventMailbox: Sendable { + package typealias Event = WebInspectorPageEvent + + private enum PendingElement { + case reset(WebInspectorPage.Generation) + case event(WebInspectorPage.Generation, Element) + } + + private enum Terminal { + case finished + case failed(any Error) + } + + private struct State { + var pendingElements: [PendingElement] = [] + var pendingElementStartIndex = 0 + var pendingEventCount = 0 + var waiter: CheckedContinuation? + var terminal: Terminal? + + mutating func removeFirstElement() -> PendingElement? { + guard pendingElementStartIndex < pendingElements.count else { + return nil + } + let element = pendingElements[pendingElementStartIndex] + pendingElementStartIndex += 1 + if case .event = element { + pendingEventCount -= 1 + } + if pendingElementStartIndex == pendingElements.count { + pendingElements.removeAll(keepingCapacity: true) + pendingElementStartIndex = 0 + } else if pendingElementStartIndex >= 64, + pendingElementStartIndex * 2 >= pendingElements.count { + pendingElements.removeFirst(pendingElementStartIndex) + pendingElementStartIndex = 0 + } + return element + } + + mutating func appendReset(_ generation: WebInspectorPage.Generation) { + if pendingElementStartIndex < pendingElements.count, + case .reset = pendingElements[pendingElements.count - 1] { + pendingElements[pendingElements.count - 1] = .reset(generation) + } else { + pendingElements.append(.reset(generation)) + } + } + + mutating func appendEvent( + _ generation: WebInspectorPage.Generation, + _ event: Element + ) { + pendingElements.append(.event(generation, event)) + pendingEventCount += 1 + } + } + + private let capacity: Int? + private let state = Mutex(State()) + + package init(capacity: Int?) { + self.capacity = capacity + } + + package func makeStream() -> AsyncThrowingStream { + AsyncThrowingStream { [self] in + try await next() + } + } + + package func yieldReset( + _ generation: WebInspectorPage.Generation + ) -> WebInspectorEventDeliveryResult { + let (result, waiter) = state.withLock { state in + guard state.terminal == nil else { + return ( + WebInspectorEventDeliveryResult.terminated, + nil as CheckedContinuation? + ) + } + if let waiter = state.waiter { + state.waiter = nil + return (WebInspectorEventDeliveryResult.enqueued, waiter) + } + // A reset never consumes protocol-event capacity. Preserve all + // events that precede the binding boundary, while folding a run + // of adjacent resets to the latest physical generation. + state.appendReset(generation) + return ( + WebInspectorEventDeliveryResult.enqueued, + nil as CheckedContinuation? + ) + } + waiter?.resume(returning: .reset(generation)) + return result + } + + package func yieldEvent( + _ generation: WebInspectorPage.Generation, + _ event: Element + ) -> WebInspectorEventDeliveryResult { + let (result, waiter) = state.withLock { state in + guard state.terminal == nil else { + return ( + WebInspectorEventDeliveryResult.terminated, + nil as CheckedContinuation? + ) + } + if let waiter = state.waiter { + state.waiter = nil + return (WebInspectorEventDeliveryResult.enqueued, waiter) + } + if let capacity, state.pendingEventCount >= capacity { + return ( + WebInspectorEventDeliveryResult.dropped, + nil as CheckedContinuation? + ) + } + state.appendEvent(generation, event) + return ( + WebInspectorEventDeliveryResult.enqueued, + nil as CheckedContinuation? + ) + } + waiter?.resume(returning: .event(generation, event)) + return result + } + + package func finish(throwing error: (any Error)? = nil) { + let waiter = state.withLock { state in + guard state.terminal == nil else { + return nil as CheckedContinuation? + } + if let error { + state.terminal = .failed(error) + } else { + state.terminal = .finished + } + guard state.pendingElements.isEmpty else { + return nil + } + let waiter = state.waiter + state.waiter = nil + return waiter + } + guard let waiter else { + return + } + if let error { + waiter.resume(throwing: error) + } else { + waiter.resume(returning: nil) + } + } + + private func next() async throws -> Event? { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let result = state.withLock { state -> Result? in + if let element = state.removeFirstElement() { + switch element { + case let .reset(generation): + return .success(.reset(generation)) + case let .event(generation, event): + return .success(.event(generation, event)) + } + } + if let terminal = state.terminal { + switch terminal { + case .finished: + return .success(nil) + case let .failed(error): + return .failure(error) + } + } + precondition(state.waiter == nil, "A Web Inspector event stream cannot have concurrent next() calls.") + state.waiter = continuation + return nil + } + if let result { + continuation.resume(with: result) + } + } + } onCancel: { [self] in + cancel() + } + } + + private func cancel() { + let error = CancellationError() + let waiter = state.withLock { state in + state.pendingElements.removeAll(keepingCapacity: true) + state.pendingElementStartIndex = 0 + state.pendingEventCount = 0 + state.terminal = .failed(error) + let waiter = state.waiter + state.waiter = nil + return waiter + } + waiter?.resume(throwing: error) + } +} + +/// A stable logical handle for the inspected page. +/// +/// The handle survives physical WebKit target replacement. Domain operations +/// resolve the current physical target through the connection core. +public struct WebInspectorPage: Sendable { + /// Identifies one physical binding of the logical inspected page. + public struct Generation: Hashable, Sendable { + package let rawValue: UInt64 + + package init(rawValue: UInt64) { + self.rawValue = rawValue + } + } + + package let proxyReference: WebInspectorProxyReference + package let authority: WebInspectorCommandAuthority + + package init(proxy: WebInspectorProxy) { + proxyReference = WebInspectorProxyReference(proxy) + authority = .direct + } + + package init( + proxy: WebInspectorProxy, + commandAuthorization: ConnectionModelCommandAuthorization + ) { + proxyReference = WebInspectorProxyReference(proxy) + authority = .modelFeed(commandAuthorization) + } + + /// The current physical page generation. + public var generation: Generation { + get async throws { + guard let proxy = proxyReference.resolve() else { + throw WebInspectorProxyError.closed + } + switch authority { + case .direct: + return try await proxy.pageGeneration() + case let .modelFeed(authorization): + return authorization.generation + } + } + } + + /// A target-scoped handle for DOM protocol commands and events. + public var dom: DOM { + DOM(endpoint: endpoint) + } + + /// A target-scoped handle for CSS protocol commands and events. + public var css: CSS { + CSS(endpoint: endpoint) + } + + /// A target-scoped handle for Network protocol commands and events. + public var network: Network { + Network(endpoint: endpoint) + } + + /// A target-scoped handle for Console protocol commands and events. + public var console: Console { + Console(endpoint: endpoint) + } + + /// A target-scoped handle for Runtime protocol commands and events. + public var runtime: Runtime { + Runtime(endpoint: endpoint) + } + + /// A target-scoped handle for Page protocol commands. + public var page: Page { + Page(endpoint: endpoint) + } + + private var endpoint: DomainEndpoint { + DomainEndpoint( + proxyReference: proxyReference, + targetID: .currentPage, + route: .currentPage, + authority: authority + ) + } +} + +/// An event from one physical binding of a logical inspected page. +public enum WebInspectorPageEvent: Sendable { + /// Invalidates state from the preceding physical page binding. + /// + /// A slow consumer still receives protocol events in binding order: + /// old-generation events, then the reset, then new-generation events. + /// Adjacent unconsumed resets with no event between them are coalesced to + /// the latest generation, and resets do not consume event-buffer capacity. + case reset(WebInspectorPage.Generation) + + /// Delivers a domain event from the supplied physical page binding. + case event(WebInspectorPage.Generation, Element) +} + +/// Preserves both failures when a structured operation and its cleanup fail. +public struct WebInspectorScopeError: Error { + /// The error thrown by the operation body. + public let operationError: any Error + + /// The error thrown while balancing the scope's capability lease. + public let cleanupError: any Error + + /// Creates an error containing both the operation and cleanup failures. + public init(operationError: any Error, cleanupError: any Error) { + self.operationError = operationError + self.cleanupError = cleanupError + } +} + +package struct WebInspectorProxyEventScopeID: Hashable, Sendable { + package let rawValue: UUID + + package init(rawValue: UUID = UUID()) { + self.rawValue = rawValue + } +} + +package struct WebInspectorProxyEventScope: Sendable { + package let id: WebInspectorProxyEventScopeID + package let events: AsyncThrowingStream, any Error> + + package init( + id: WebInspectorProxyEventScopeID, + events: AsyncThrowingStream, any Error> + ) { + self.id = id + self.events = events + } +} + +package enum WebInspectorEventDeliveryResult: Sendable { + case enqueued + case dropped + case terminated + case mismatchedEvent +} + +package struct WebInspectorEventSink: Sendable { + package let id: WebInspectorProxyEventScopeID + package let route: RoutingTargetID + package let targetID: WebInspectorTarget.ID + package let domain: WebInspectorProxyEventDomain + package let yieldReset: @Sendable (WebInspectorPage.Generation) -> WebInspectorEventDeliveryResult + package let yieldEvent: @Sendable ( + WebInspectorPage.Generation, + WebInspectorProxyEvent + ) -> WebInspectorEventDeliveryResult + package let finish: @Sendable ((any Error)?) -> Void + + package init( + id: WebInspectorProxyEventScopeID, + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + domain: WebInspectorProxyEventDomain, + mailbox: WebInspectorEventMailbox, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) { + self.id = id + self.route = route + self.targetID = targetID + self.domain = domain + yieldReset = { generation in + mailbox.yieldReset(generation) + } + yieldEvent = { generation, proxyEvent in + guard let event = extract(proxyEvent) else { + return .mismatchedEvent + } + return mailbox.yieldEvent(generation, event) + } + finish = { error in + mailbox.finish(throwing: error) + } + } +} + +package func withWebInspectorEventScope( + backend: any WebInspectorProxyBackend, + targetID: WebInspectorTarget.ID, + route: RoutingTargetID, + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + isolation: isolated (any Actor)? = #isolation, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element?, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output +) async throws -> Output { + _ = isolation + _ = buffering.capacity + let scope = try await backend.acquireEventScope( + route: route, + targetID: targetID, + domain: domain, + buffering: buffering, + extract: extract + ) + + let operationResult: Result + do { + try Task.checkCancellation() + operationResult = .success(try await operation(scope.events)) + } catch { + operationResult = .failure(error) + } + + let cleanupResult: Result + do { + try await backend.releaseEventScope(scope.id) + cleanupResult = .success(()) + } catch { + cleanupResult = .failure(error) + } + + switch (operationResult, cleanupResult) { + case let (.success(value), .success): + return value + case let (.success, .failure(cleanupError)): + throw cleanupError + case let (.failure(operationError), .success): + throw operationError + case let (.failure(operationError), .failure(cleanupError)): + throw WebInspectorScopeError( + operationError: operationError, + cleanupError: cleanupError + ) + } +} diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift new file mode 100644 index 00000000..0a11f2df --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventProjection.swift @@ -0,0 +1,568 @@ +import Foundation + +package enum ConnectionEventProjection { + package nonisolated static func projectedDOMBootstrapNode( + _ node: DOM.Node, + target: ModelTarget + ) -> DOM.Node { + guard target.kind == .frame else { + return node + } + return scopedDOMNode(node, targetRawValue: target.id.rawValue) + } + + package nonisolated static func shouldDeliver( + _ event: ProtocolEvent, + to route: RoutingTargetID, + in snapshot: TransportSession.Snapshot + ) -> Bool { + switch route.storage { + case let .target(rawValue): + if let targetID = event.targetID { + return targetID.rawValue == rawValue + } + return snapshot.currentMainPageTargetID?.rawValue == rawValue + case .currentPage: + if event.domain == .target, + event.method == "Target.targetDestroyed" { + // The registry has already dropped the destroyed record, so + // route by the event-time fact: only the destruction of the + // then-current main page belongs to the semantic page route. + return event.destroyedCurrentMainPageTarget + } + guard let currentMainPageTargetID = snapshot.currentMainPageTargetID else { + return false + } + guard let targetID = event.targetID else { + return true + } + if targetID == currentMainPageTargetID { + return true + } + guard let record = snapshot.targetsByID[targetID] else { + return false + } + // WebKit may report subframe domain activity on frame targets while + // WebInspectorKit exposes a semantic current page. + switch event.domain { + case .dom: + guard event.method != "DOM.documentUpdated" else { + return false + } + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + case .inspector: + // Inspector excludes frame targets in WebKit's protocol. A + // frame-origin inspect event is not a state this projection + // boundary supports or rewrites into a page event. + return false + case .network: + // WebKit's page/ProxyingNetworkAgent owns process-wide + // Network.enable. This branch only projects target-wrapped + // frame Network events if WebKit emits them. + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + case .css: + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + case .console: + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + case .runtime: + return Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) + default: + return false + } + } + } + + private nonisolated static func isCurrentPageFrameTarget( + _ record: ProtocolTarget.Record, + in snapshot: TransportSession.Snapshot, + currentMainPageTargetID: ProtocolTarget.ID + ) -> Bool { + guard record.kind == .frame, + let mainFrameID = snapshot.targetsByID[currentMainPageTargetID]?.frameID else { + return false + } + + guard var parentFrameID = record.parentFrameID else { + // WebKit may omit parentFrameId for a cross-origin frame target even + // though its frameId differs from the current page's main frame. + // TransportTargetRegistry already classifies that target as .frame; + // the current-page route must preserve the same semantic boundary + // or picker, DOM, and Network events are silently filtered. + guard let frameID = record.frameID else { + return false + } + return frameID != mainFrameID + } + + var visitedFrameIDs = Set() + while visitedFrameIDs.insert(parentFrameID).inserted { + if parentFrameID == mainFrameID { + return true + } + guard let parentTargetID = snapshot.frameTargetIDsByFrameID[parentFrameID], + let parentRecord = snapshot.targetsByID[parentTargetID], + let nextParentFrameID = parentRecord.parentFrameID else { + return false + } + parentFrameID = nextParentFrameID + } + return false + } + + package nonisolated static func projectedEvent( + _ proxyEvent: WebInspectorProxyEvent, + from event: ProtocolEvent, + route: RoutingTargetID, + in snapshot: TransportSession.Snapshot + ) -> WebInspectorProxyEvent { + let scopedProxyEvent = Self.scopedAgentOwnedEvent(proxyEvent, from: event, route: route, snapshot: snapshot) + guard case .currentPage = route.storage, + let targetID = event.targetID else { + return scopedProxyEvent + } + guard let currentMainPageTargetID = snapshot.currentMainPageTargetID, + targetID != currentMainPageTargetID, + let record = snapshot.targetsByID[targetID], + Self.isCurrentPageFrameTarget(record, in: snapshot, currentMainPageTargetID: currentMainPageTargetID) else { + return scopedProxyEvent + } + switch scopedProxyEvent { + case let .dom(domEvent): + return .dom(Self.scopedDOMEvent(domEvent, targetRawValue: targetID.rawValue)) + case let .css(cssEvent): + return .css(Self.scopedCSSEvent(cssEvent, targetRawValue: targetID.rawValue)) + case let .network(networkEvent): + return .network(Self.scopedNetworkEvent(networkEvent, targetRawValue: targetID.rawValue)) + default: + return scopedProxyEvent + } + } + + private nonisolated static func scopedAgentOwnedEvent( + _ proxyEvent: WebInspectorProxyEvent, + from event: ProtocolEvent, + route: RoutingTargetID, + snapshot: TransportSession.Snapshot + ) -> WebInspectorProxyEvent { + let targetScopeRawValue = runtimeAgentScopeRawValue(for: event, route: route, snapshot: snapshot) + switch proxyEvent { + case let .runtime(runtimeEvent): + return .runtime(scopedRuntimeEvent(runtimeEvent, targetScopeRawValue: targetScopeRawValue)) + case let .console(targetedEvent): + return .console(Console.TargetedEvent( + event: scopedConsoleEvent(targetedEvent.event, targetScopeRawValue: targetScopeRawValue), + targetID: targetedEvent.targetID + )) + case .targetLifecycle, .dom, .inspector, .css, .network: + return proxyEvent + } + } + + private nonisolated static func runtimeAgentScopeRawValue( + for event: ProtocolEvent, + route: RoutingTargetID, + snapshot: TransportSession.Snapshot + ) -> String? { + let agentTargetID = event.sourceTargetID ?? event.targetID + guard let agentTargetID else { + return nil + } + if agentTargetID == snapshot.currentMainPageTargetID { + return nil + } + if let record = snapshot.targetsByID[agentTargetID], + record.kind == .page, + record.parentFrameID == nil { + return nil + } + return agentTargetID.rawValue + } + + private nonisolated static func scopedDOMEvent( + _ event: DOM.Event, + targetRawValue: String + ) -> DOM.Event { + switch event { + case .documentUpdated: + .documentUpdated + case let .setChildNodes(parent, nodes): + .setChildNodes( + parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), + nodes: nodes.map { scopedDOMNode($0, targetRawValue: targetRawValue) } + ) + case let .detachedRoot(node): + .detachedRoot(scopedDOMNode(node, targetRawValue: targetRawValue)) + case let .childNodeInserted(parent, previous, node): + .childNodeInserted( + parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), + previous: previous.map { scopedDOMNodeID($0, targetRawValue: targetRawValue) }, + node: scopedDOMNode(node, targetRawValue: targetRawValue) + ) + case let .childNodeRemoved(parent, node): + .childNodeRemoved( + parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), + node: scopedDOMNodeID(node, targetRawValue: targetRawValue) + ) + case let .childNodeCountUpdated(node, count): + .childNodeCountUpdated(scopedDOMNodeID(node, targetRawValue: targetRawValue), count: count) + case let .attributeModified(node, name, value): + .attributeModified(scopedDOMNodeID(node, targetRawValue: targetRawValue), name: name, value: value) + case let .attributeRemoved(node, name): + .attributeRemoved(scopedDOMNodeID(node, targetRawValue: targetRawValue), name: name) + case let .inlineStyleInvalidated(nodes): + .inlineStyleInvalidated(nodes.map { scopedDOMNodeID($0, targetRawValue: targetRawValue) }) + case let .characterDataModified(node, value): + .characterDataModified(scopedDOMNodeID(node, targetRawValue: targetRawValue), value: value) + case let .shadowRootPushed(host, root): + .shadowRootPushed( + host: scopedDOMNodeID(host, targetRawValue: targetRawValue), + root: scopedDOMNode(root, targetRawValue: targetRawValue) + ) + case let .shadowRootPopped(host, root): + .shadowRootPopped( + host: scopedDOMNodeID(host, targetRawValue: targetRawValue), + root: scopedDOMNodeID(root, targetRawValue: targetRawValue) + ) + case let .pseudoElementAdded(parent, element): + .pseudoElementAdded( + parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), + element: scopedDOMNode(element, targetRawValue: targetRawValue) + ) + case let .pseudoElementRemoved(parent, element): + .pseudoElementRemoved( + parent: scopedDOMNodeID(parent, targetRawValue: targetRawValue), + element: scopedDOMNodeID(element, targetRawValue: targetRawValue) + ) + case let .willDestroyDOMNode(node): + .willDestroyDOMNode(scopedDOMNodeID(node, targetRawValue: targetRawValue)) + case let .inspect(node): + .inspect(scopedDOMNodeID(node, targetRawValue: targetRawValue)) + case let .unknown(rawEvent): + .unknown(rawEvent) + } + } + + private nonisolated static func scopedDOMNode( + _ node: DOM.Node, + targetRawValue: String + ) -> DOM.Node { + DOM.Node( + id: scopedDOMNodeID(node.id, targetRawValue: targetRawValue), + nodeType: node.nodeType, + nodeName: node.nodeName, + localName: node.localName, + nodeValue: node.nodeValue, + frameID: node.frameID, + documentURL: node.documentURL, + baseURL: node.baseURL, + attributes: node.attributes, + attributeList: node.attributeList, + childNodeCount: node.childNodeCount, + children: node.children?.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + contentDocument: node.contentDocument.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + shadowRoots: node.shadowRoots.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + templateContent: node.templateContent.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + beforePseudoElement: node.beforePseudoElement.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + otherPseudoElements: node.otherPseudoElements.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + afterPseudoElement: node.afterPseudoElement.map { scopedDOMNode($0, targetRawValue: targetRawValue) }, + pseudoType: node.pseudoType, + shadowRootType: node.shadowRootType + ) + } + + private nonisolated static func scopedDOMNodeID( + _ id: DOM.Node.ID, + targetRawValue: String + ) -> DOM.Node.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return DOM.Node.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) + } + + private nonisolated static func scopedCSSEvent( + _ event: CSS.Event, + targetRawValue: String + ) -> CSS.Event { + switch event { + case let .styleSheetChanged(id): + .styleSheetChanged(scopedStyleSheetID(id, targetRawValue: targetRawValue)) + case let .styleSheetAdded(header): + .styleSheetAdded(CSS.StyleSheetHeader( + styleSheetID: scopedStyleSheetID(header.styleSheetID, targetRawValue: targetRawValue), + frameID: header.frameID, + sourceURL: header.sourceURL, + origin: header.origin, + title: header.title, + disabled: header.disabled, + isInline: header.isInline, + startLine: header.startLine, + startColumn: header.startColumn + )) + case let .styleSheetRemoved(id): + .styleSheetRemoved(scopedStyleSheetID(id, targetRawValue: targetRawValue)) + case .mediaQueryResultChanged: + .mediaQueryResultChanged + case let .nodeLayoutFlagsChanged(id): + .nodeLayoutFlagsChanged(scopedDOMNodeID(id, targetRawValue: targetRawValue)) + case let .unknown(rawEvent): + .unknown(rawEvent) + } + } + + private nonisolated static func scopedStyleSheetID( + _ id: CSS.StyleSheet.ID, + targetRawValue: String + ) -> CSS.StyleSheet.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return CSS.StyleSheet.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) + } + + private nonisolated static func scopedRuntimeEvent( + _ event: Runtime.Event, + targetScopeRawValue: String? + ) -> Runtime.Event { + guard let targetScopeRawValue else { + return event + } + switch event { + case let .executionContextCreated(context): + return .executionContextCreated(Runtime.ExecutionContext( + id: scopedExecutionContextID(context.id, targetRawValue: targetScopeRawValue), + name: context.name, + frameID: context.frameID, + kind: context.kind + )) + case let .executionContextDestroyed(id): + return .executionContextDestroyed(scopedExecutionContextID(id, targetRawValue: targetScopeRawValue)) + case .executionContextsCleared: + return .executionContextsCleared + case let .unknown(rawEvent): + return .unknown(rawEvent) + } + } + + private nonisolated static func scopedExecutionContextID( + _ id: Runtime.ExecutionContext.ID, + targetRawValue: String + ) -> Runtime.ExecutionContext.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return Runtime.ExecutionContext.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) + } + + private nonisolated static func scopedConsoleEvent( + _ event: Console.Event, + targetScopeRawValue: String? + ) -> Console.Event { + guard let targetScopeRawValue else { + return event + } + switch event { + case let .messageAdded(message): + return .messageAdded(scopedConsoleMessage(message, targetRawValue: targetScopeRawValue)) + case let .messageRepeatCountUpdated(count, timestamp): + return .messageRepeatCountUpdated(count: count, timestamp: timestamp) + case let .messagesCleared(reason): + return .messagesCleared(reason: reason) + case let .unknown(rawEvent): + return .unknown(rawEvent) + } + } + + private nonisolated static func scopedConsoleMessage( + _ message: Console.Message, + targetRawValue: String + ) -> Console.Message { + Console.Message( + source: message.source, + level: message.level, + type: message.type, + text: message.text, + url: message.url, + line: message.line, + column: message.column, + repeatCount: message.repeatCount, + parameters: message.parameters.map { scopedRemoteObject($0, targetRawValue: targetRawValue) }, + stackTrace: message.stackTrace, + networkRequestID: message.networkRequestID.map { + scopedNetworkRequestID($0, targetRawValue: targetRawValue) + }, + timestamp: message.timestamp + ) + } + + private nonisolated static func scopedRemoteObject( + _ object: Runtime.RemoteObject, + targetRawValue: String + ) -> Runtime.RemoteObject { + Runtime.RemoteObject( + id: object.id.map { scopedRemoteObjectID($0, targetRawValue: targetRawValue) }, + kind: object.kind, + subtype: object.subtype, + className: object.className, + description: object.description, + value: object.value, + size: object.size, + preview: object.preview + ) + } + + private nonisolated static func scopedRemoteObjectID( + _ id: Runtime.RemoteObject.ID, + targetRawValue: String + ) -> Runtime.RemoteObject.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return Runtime.RemoteObject.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) + } + + private nonisolated static func scopedNetworkEvent( + _ event: Network.Event, + targetRawValue: String + ) -> Network.Event { + switch event { + case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): + .requestWillBeSent( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + request: scopedNetworkRequest(request, targetRawValue: targetRawValue), + resourceType: resourceType, + redirectResponse: redirectResponse, + timestamp: timestamp + ) + case let .responseReceived(id, response, resourceType, timestamp): + .responseReceived( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + response: response, + resourceType: resourceType, + timestamp: timestamp + ) + case let .dataReceived(id, dataLength, encodedDataLength, timestamp): + .dataReceived( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + dataLength: dataLength, + encodedDataLength: encodedDataLength, + timestamp: timestamp + ) + case let .loadingFinished(id, timestamp, sourceMapURL, metrics): + .loadingFinished( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + timestamp: timestamp, + sourceMapURL: sourceMapURL, + metrics: metrics + ) + case let .loadingFailed(id, errorText, canceled, timestamp): + .loadingFailed( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + errorText: errorText, + canceled: canceled, + timestamp: timestamp + ) + case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): + .requestServedFromMemoryCache( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + response: response, + resourceType: resourceType, + timestamp: timestamp + ) + case let .webSocket(event): + .webSocket(scopedWebSocketEvent(event, targetRawValue: targetRawValue)) + case let .unknown(rawEvent): + .unknown(rawEvent) + } + } + + private nonisolated static func scopedWebSocketEvent( + _ event: Network.WebSocketEvent, + targetRawValue: String + ) -> Network.WebSocketEvent { + switch event { + case let .created(id, url): + .created(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), url: url) + case let .handshakeRequest(id, request, timestamp): + .handshakeRequest( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + request: scopedNetworkRequest(request, targetRawValue: targetRawValue), + timestamp: timestamp + ) + case let .handshakeResponse(id, response, timestamp): + .handshakeResponse( + id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), + response: response, + timestamp: timestamp + ) + case let .closed(id, timestamp): + .closed(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), timestamp: timestamp) + case let .frameSent(id, frame, timestamp): + .frameSent(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), frame: frame, timestamp: timestamp) + case let .frameReceived(id, frame, timestamp): + .frameReceived(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), frame: frame, timestamp: timestamp) + case let .error(id, message, timestamp): + .error(id: scopedNetworkRequestID(id, targetRawValue: targetRawValue), message: message, timestamp: timestamp) + case let .other(rawEvent): + .other(rawEvent) + } + } + + private nonisolated static func scopedNetworkRequest( + _ request: Network.Request, + targetRawValue: String + ) -> Network.Request { + Network.Request( + id: scopedNetworkRequestID(request.id, targetRawValue: targetRawValue), + url: request.url, + method: request.method, + headers: request.headers, + postData: request.postData, + referrerPolicy: request.referrerPolicy, + integrity: request.integrity, + backendResourceIdentifier: request.backendResourceIdentifier + ) + } + + private nonisolated static func scopedNetworkRequestID( + _ id: Network.Request.ID, + targetRawValue: String + ) -> Network.Request.ID { + guard id.targetScopeRawValue == nil else { + return id + } + return Network.Request.ID(id.rawValue, scopedToTargetRawValue: targetRawValue) + } + + package nonisolated static func lifecycleTarget( + for event: ProtocolEvent, + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + in snapshot: TransportSession.Snapshot + ) -> WebInspectorLifecycleTarget? { + guard event.domain == .target, + event.method == "Target.didCommitProvisionalTarget", + let protocolTargetID = event.targetID else { + return nil + } + guard let record = snapshot.targetsByID[protocolTargetID] else { + return nil + } + return WebInspectorLifecycleTarget( + semanticID: Self.semanticTargetID(for: route, targetID: targetID), + record: record + ) + } + + private nonisolated static func semanticTargetID( + for route: RoutingTargetID, + targetID: WebInspectorTarget.ID + ) -> WebInspectorTarget.ID { + switch route.storage { + case .currentPage: + .currentPage + case .target: + targetID + } + } +} diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift new file mode 100644 index 00000000..2664b64e --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionEventScopeRegistry.swift @@ -0,0 +1,246 @@ +import Foundation + +struct ConnectionCapabilityKey: Hashable, Sendable { + var route: RoutingTargetID + var targetID: WebInspectorTarget.ID + var domain: WebInspectorProxyEventDomain +} + +enum ConnectionCapabilityActivationPlan { + static func domains( + for requestedDomains: [WebInspectorProxyEventDomain], + includePageDependencyForCSS: Bool + ) -> [WebInspectorProxyEventDomain] { + var seen: Set = [] + var result: [WebInspectorProxyEventDomain] = [] + for requestedDomain in requestedDomains { + for domain in dependencies( + for: requestedDomain, + includePageDependencyForCSS: includePageDependencyForCSS + ) { + if seen.insert(domain).inserted { + result.append(domain) + } + } + } + return result + } + + private static func dependencies( + for domain: WebInspectorProxyEventDomain, + includePageDependencyForCSS: Bool + ) -> [WebInspectorProxyEventDomain] { + switch domain { + case .css where includePageDependencyForCSS: + // WebKit 624's InspectorStyleSheet retains the enabled Page + // agent and dereferences it while publishing stylesheet headers. + // Keep Page enabled for the entire CSS capability lifetime. Newer + // WebKit revisions no longer require this on page targets, but + // preserve the protocol domain and accept the same ordering. + // Frame targets use FrameCSSAgent and may not expose Page at all. + [.page, .css] + default: + [domain] + } + } +} + +enum ConnectionCapabilityLeaseOwner: Hashable, Sendable { + case eventScope(WebInspectorProxyEventScopeID) + case modelFeed(ConnectionModelFeedID, ModelDomain) + case modelElementPicker(ConnectionModelFeedID) +} + +struct ConnectionEventScopeRegistry { + struct Entry: Sendable { + var sink: WebInspectorEventSink? + var capabilities: [ConnectionCapabilityKey] + var capacity: Int? + } + + private(set) var entries: [WebInspectorProxyEventScopeID: Entry] = [:] + + var isEmpty: Bool { + entries.isEmpty + } + + mutating func insert( + _ sink: WebInspectorEventSink, + capacity: Int?, + generation: WebInspectorPage.Generation + ) { + precondition(entries[sink.id] == nil, "Duplicate Web Inspector event scope identifier.") + entries[sink.id] = Entry(sink: sink, capabilities: [], capacity: capacity) + handleInitialDelivery(sink.yieldReset(generation), id: sink.id, capacity: capacity) + } + + mutating func appendCapability( + _ capability: ConnectionCapabilityKey, + to id: WebInspectorProxyEventScopeID + ) { + guard var entry = entries[id] else { + preconditionFailure("A Web Inspector event scope lost its registration during capability acquisition.") + } + precondition( + !entry.capabilities.contains(capability), + "A Web Inspector event scope acquired the same capability twice." + ) + entry.capabilities.append(capability) + entries[id] = entry + } + + mutating func remove(_ id: WebInspectorProxyEventScopeID) -> Entry? { + entries.removeValue(forKey: id) + } + + func sinks(for domain: WebInspectorProxyEventDomain) -> [WebInspectorEventSink] { + entries.values.compactMap { entry in + guard entry.sink?.domain == domain else { + return nil + } + return entry.sink + } + } + + mutating func markTerminated(_ id: WebInspectorProxyEventScopeID) { + entries[id]?.sink = nil + } + + mutating func publishReset( + _ generation: WebInspectorPage.Generation, + where predicate: (WebInspectorEventSink) -> Bool + ) { + for (id, entry) in Array(entries) { + guard let sink = entry.sink, predicate(sink) else { + continue + } + handleDelivery(sink.yieldReset(generation), id: id, capacity: entry.capacity) + } + } + + mutating func finishSubscribers(with error: (any Error)?) { + finishSubscribers(where: { _ in true }, with: error) + } + + mutating func finishSubscribers( + where predicate: (WebInspectorEventSink) -> Bool, + with error: (any Error)? + ) { + for (id, entry) in Array(entries) { + guard let sink = entry.sink, predicate(sink) else { + continue + } + sink.finish(error) + entries[id]?.sink = nil + } + } + + mutating func finishAndRemoveAll(with error: (any Error)? = nil) { + finishSubscribers(with: error) + entries.removeAll() + } + + mutating func handleDelivery( + _ result: WebInspectorEventDeliveryResult, + id: WebInspectorProxyEventScopeID, + capacity: Int? + ) { + switch result { + case .enqueued: + break + case .dropped: + guard let capacity else { + preconditionFailure("An unbounded Web Inspector event stream dropped an event.") + } + entries[id]?.sink?.finish(WebInspectorProxyError.eventBufferOverflow(capacity: capacity)) + entries[id]?.sink = nil + case .terminated: + entries[id]?.sink = nil + case .mismatchedEvent: + // ConnectionCore owns these connection-terminal failures because + // it also owns ingress ordering and all peer subscribers. + break + } + } + + private mutating func handleInitialDelivery( + _ result: WebInspectorEventDeliveryResult, + id: WebInspectorProxyEventScopeID, + capacity: Int? + ) { + switch result { + case .enqueued: + break + case .terminated: + entries[id]?.sink = nil + case .dropped: + preconditionFailure("The initial reset did not fit in an empty event buffer of capacity \(capacity ?? -1).") + case .mismatchedEvent: + preconditionFailure("An initial reset cannot decode a protocol event.") + } + } +} + +struct ConnectionCapabilityRegistry { + enum PhysicalState: Sendable { + case inactive(generation: WebInspectorPage.Generation) + case enabling( + generation: WebInspectorPage.Generation, + operationID: UInt64, + mustDisableAfterEnable: Bool + ) + case enabled(generation: WebInspectorPage.Generation) + case disabling(generation: WebInspectorPage.Generation, operationID: UInt64) + + var generation: WebInspectorPage.Generation { + switch self { + case let .inactive(generation), + let .enabling(generation, _, _), + let .enabled(generation), + let .disabling(generation, _): + generation + } + } + } + + struct State: Sendable { + var physical: PhysicalState + var leaseOwners: Set = [] + var failedLeaseOwners: Set = [] + var activatedLeaseOwners: Set = [] + var activationWaiters: [ConnectionCapabilityLeaseOwner: ReplyPromise] = [:] + var releaseWaiters: [ConnectionCapabilityLeaseOwner: ReplyPromise] = [:] + + var desiredLeaseOwners: Set { + leaseOwners.subtracting(failedLeaseOwners) + } + + var desiredCount: Int { + desiredLeaseOwners.count + } + + var hasActivatedDesiredLease: Bool { + !activatedLeaseOwners.intersection(desiredLeaseOwners).isEmpty + } + } + + var states: [ConnectionCapabilityKey: State] = [:] + private var nextOperationID: UInt64 = 0 + + mutating func allocateOperationID() -> UInt64 { + nextOperationID &+= 1 + return nextOperationID + } + + mutating func removeEmptyState(for key: ConnectionCapabilityKey) { + guard let state = states[key], + state.leaseOwners.isEmpty, + state.activationWaiters.isEmpty, + state.releaseWaiters.isEmpty else { + return + } + if case .inactive = state.physical { + states.removeValue(forKey: key) + } + } +} diff --git a/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift new file mode 100644 index 00000000..2c80bde7 --- /dev/null +++ b/Sources/WebInspectorProxyKit/Transport/ConnectionModelFeed.swift @@ -0,0 +1,459 @@ +import Foundation +import Synchronization + +package enum ModelDomain: Hashable, Sendable { + case dom + case css + case network + case console + case runtime + + static let acquisitionOrder: [ModelDomain] = [ + .dom, + .css, + .network, + .console, + .runtime, + ] + + static func ordered( + _ domains: Set + ) -> [ModelDomain] { + acquisitionOrder.filter(domains.contains) + } + + static func normalized( + _ domains: Set + ) -> Set { + guard domains.contains(.css) else { + return domains + } + var normalizedDomains = domains + normalizedDomains.insert(.dom) + return normalizedDomains + } + + var capabilityDependencies: [WebInspectorProxyEventDomain] { + switch self { + case .dom: + [.dom] + case .css: + [.dom, .css] + case .network: + [.network] + case .console: + [.console] + case .runtime: + [.runtime] + } + } + + var replayCapability: WebInspectorProxyEventDomain? { + switch self { + case .dom: + nil + case .css: + .css + case .network: + .network + case .console: + .console + case .runtime: + .runtime + } + } +} + +package struct ModelTarget: Equatable, Sendable { + package let id: WebInspectorTarget.ID + package let kind: WebInspectorTarget.Kind + package let frameID: FrameID? + package let parentFrameID: FrameID? + + package init( + id: WebInspectorTarget.ID, + kind: WebInspectorTarget.Kind, + frameID: FrameID?, + parentFrameID: FrameID? + ) { + self.id = id + self.kind = kind + self.frameID = frameID + self.parentFrameID = parentFrameID + } +} + +extension ModelTarget { + init?(record: ProtocolTarget.Record) { + guard let kind = WebInspectorTarget.Kind(protocolKind: record.kind) else { + return nil + } + self.init( + id: WebInspectorTarget.ID(record.id.rawValue), + kind: kind, + frameID: record.frameID.map { FrameID($0.rawValue) }, + parentFrameID: record.parentFrameID.map { FrameID($0.rawValue) } + ) + } +} + +package struct ModelTargetSnapshot: Equatable, Sendable { + /// The physical target currently bound to the semantic current-page feed. + package let currentPageID: WebInspectorTarget.ID + + /// The physical main page followed by its frame targets in deterministic + /// parent-before-child order. + package let targets: [ModelTarget] + + package init( + currentPageID: WebInspectorTarget.ID, + targets: [ModelTarget] + ) { + self.currentPageID = currentPageID + self.targets = targets + } +} + +package enum ModelTargetLifecycleEvent: Sendable { + case targetCreated(ModelTarget) + case targetDestroyed(ModelTarget) + case didCommitProvisionalTarget( + oldTargetID: WebInspectorTarget.ID, + newTarget: ModelTarget + ) + case frameNavigated(WebInspectorPageFrameLifecycle) + case frameDetached(frameID: FrameID) +} + +package enum ModelProtocolEvent: Sendable { + case target(ModelTargetLifecycleEvent) + case dom(target: ModelTarget, event: DOM.Event) + case inspector(target: ModelTarget, event: Inspector.Event) + case css(target: ModelTarget, event: CSS.Event) + case network(target: ModelTarget, event: Network.Event) + case console(target: ModelTarget, event: Console.Event) + case runtime(target: ModelTarget, event: Runtime.Event) +} + +package struct ModelDocumentEpoch: Hashable, Sendable { + package let rawValue: UInt64 + + package init(rawValue: UInt64) { + self.rawValue = rawValue + } +} + +package enum ModelBootstrapSnapshot: Sendable { + case domDocument( + target: ModelTarget, + documentEpoch: ModelDocumentEpoch, + root: DOM.Node + ) +} + +package enum ConnectionModelFeedRecord: Sendable { + case reset(WebInspectorPage.Generation) + case targetSnapshot( + generation: WebInspectorPage.Generation, + through: UInt64, + snapshot: ModelTargetSnapshot + ) + /// The authoritative document-identity boundary for one physical target. + /// + /// Core publishes this after advancing `documentEpoch`, and before the + /// replacement DOM bootstrap or any later DOM/CSS delta for that target. + /// `DOM.documentUpdated` is not also projected as a model protocol event. + case domDocumentInvalidated( + generation: WebInspectorPage.Generation, + sequence: UInt64, + target: ModelTarget, + documentEpoch: ModelDocumentEpoch + ) + case event( + generation: WebInspectorPage.Generation, + sequence: UInt64, + payload: ModelProtocolEvent + ) + case replayComplete( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + through: UInt64 + ) + case bootstrapSnapshot( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + sequence: UInt64, + payload: ModelBootstrapSnapshot + ) + case bootstrapComplete( + generation: WebInspectorPage.Generation, + domain: ModelDomain, + through: UInt64 + ) + case synchronizationComplete( + generation: WebInspectorPage.Generation, + through: UInt64 + ) +} + +package struct ConnectionModelFeedID: Hashable, Sendable { + package let rawValue: UUID + + package init(rawValue: UUID = UUID()) { + self.rawValue = rawValue + } +} + +package enum ConnectionModelFeedDeliveryResult: Sendable { + case enqueued + case terminated +} + +package enum ConnectionModelFeedError: Error, Equatable, Sendable { + case connectionAlreadyUsedByDirectConsumer + case alreadyOpen + /// A configured domain rejected the capability or document bootstrap that + /// is required to construct or refresh its authoritative model state. + case bootstrapFailed(domain: ModelDomain, message: String) + case consumerTerminated +} + +package final class ConnectionModelFeedMailbox: Sendable { + private enum Terminal { + case finished + case failed(any Error) + } + + private struct State { + var pendingRecords: [ConnectionModelFeedRecord] = [] + var pendingRecordStartIndex = 0 + var waiter: CheckedContinuation? + var terminal: Terminal? + var iteratorWasCreated = false + + mutating func removeFirstRecord() -> ConnectionModelFeedRecord? { + guard pendingRecordStartIndex < pendingRecords.count else { + return nil + } + let record = pendingRecords[pendingRecordStartIndex] + pendingRecordStartIndex += 1 + if pendingRecordStartIndex == pendingRecords.count { + pendingRecords.removeAll(keepingCapacity: true) + pendingRecordStartIndex = 0 + } else if pendingRecordStartIndex >= 64, + pendingRecordStartIndex * 2 >= pendingRecords.count { + pendingRecords.removeFirst(pendingRecordStartIndex) + pendingRecordStartIndex = 0 + } + return record + } + + var pendingRecordCount: Int { + pendingRecords.count - pendingRecordStartIndex + } + + mutating func discardPendingRecords() { + pendingRecords.removeAll(keepingCapacity: false) + pendingRecordStartIndex = 0 + } + } + + private let state = Mutex(State()) + + package init() {} + + package func claimIterator() { + state.withLock { state in + precondition( + !state.iteratorWasCreated, + "A connection model feed supports exactly one consumer iterator." + ) + state.iteratorWasCreated = true + } + } + + package func enqueue( + _ record: ConnectionModelFeedRecord + ) -> ConnectionModelFeedDeliveryResult { + let result = state.withLock { state -> ( + result: ConnectionModelFeedDeliveryResult, + waiter: CheckedContinuation? + ) in + guard state.terminal == nil else { + return (.terminated, nil) + } + if let waiter = state.waiter { + state.waiter = nil + return (.enqueued, waiter) + } + state.pendingRecords.append(record) + return (.enqueued, nil) + } + result.waiter?.resume(returning: record) + return result.result + } + + package func finish(throwing error: (any Error)? = nil) { + let waiter = state.withLock { state in + guard state.terminal == nil else { + return nil as CheckedContinuation? + } + if let error { + state.terminal = .failed(error) + } else { + state.terminal = .finished + } + guard state.pendingRecordCount == 0 else { + return nil + } + let waiter = state.waiter + state.waiter = nil + return waiter + } + guard let waiter else { + return + } + if let error { + waiter.resume(throwing: error) + } else { + waiter.resume(returning: nil) + } + } + + package func poison(throwing error: any Error) { + let waiter = state.withLock { state in + state.discardPendingRecords() + guard state.terminal == nil else { + return nil as CheckedContinuation? + } + state.terminal = .failed(error) + let waiter = state.waiter + state.waiter = nil + return waiter + } + waiter?.resume(throwing: error) + } + + package func abandon() { + let waiter = state.withLock { state in + guard state.terminal == nil else { + return nil as CheckedContinuation? + } + state.terminal = .finished + guard state.pendingRecordCount == 0 else { + return nil + } + let waiter = state.waiter + state.waiter = nil + return waiter + } + waiter?.resume(returning: nil) + } + + fileprivate func next() async throws -> ConnectionModelFeedRecord? { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let result = state.withLock { state -> Result? in + if let record = state.removeFirstRecord() { + return .success(record) + } + if let terminal = state.terminal { + switch terminal { + case .finished: + return .success(nil) + case let .failed(error): + return .failure(error) + } + } + precondition( + state.waiter == nil, + "A connection model feed cannot have concurrent next() calls." + ) + state.waiter = continuation + return nil + } + if let result { + continuation.resume(with: result) + } + } + } onCancel: { [self] in + cancelConsumer() + } + } + + private func cancelConsumer() { + let cancellation = CancellationError() + let waiter = state.withLock { state in + state.discardPendingRecords() + guard state.terminal == nil else { + return nil as CheckedContinuation? + } + state.terminal = .failed(cancellation) + let waiter = state.waiter + state.waiter = nil + return waiter + } + waiter?.resume(throwing: cancellation) + } +} + +package struct ConnectionModelFeedRecords: AsyncSequence, Sendable { + package typealias Element = ConnectionModelFeedRecord + + package struct AsyncIterator: AsyncIteratorProtocol, Sendable { + private let mailbox: ConnectionModelFeedMailbox + + fileprivate init(mailbox: ConnectionModelFeedMailbox) { + self.mailbox = mailbox + } + + package mutating func next() async throws -> ConnectionModelFeedRecord? { + try await mailbox.next() + } + } + + private let mailbox: ConnectionModelFeedMailbox + + fileprivate init(mailbox: ConnectionModelFeedMailbox) { + self.mailbox = mailbox + } + + package func makeAsyncIterator() -> AsyncIterator { + mailbox.claimIterator() + return AsyncIterator(mailbox: mailbox) + } +} + +package actor ConnectionModelFeed { + package nonisolated let id: ConnectionModelFeedID + package nonisolated let records: ConnectionModelFeedRecords + private nonisolated let owner: ConnectionCore + private nonisolated let mailbox: ConnectionModelFeedMailbox + + package init( + id: ConnectionModelFeedID, + owner: ConnectionCore, + mailbox: ConnectionModelFeedMailbox + ) { + self.id = id + self.owner = owner + self.mailbox = mailbox + records = ConnectionModelFeedRecords(mailbox: mailbox) + } + + package func close() async throws { + try await owner.closeModelFeed(id) + } + + package func acquireElementPicker() async throws { + try await owner.acquireModelFeedElementPicker(id) + } + + package func releaseElementPicker() async throws { + try await owner.releaseModelFeedElementPicker(id) + } + + isolated deinit { + mailbox.abandon() + } +} diff --git a/Sources/WebInspectorProxyKit/Transport/ReplyPromise.swift b/Sources/WebInspectorProxyKit/Transport/ReplyPromise.swift index 416489b3..d3272316 100644 --- a/Sources/WebInspectorProxyKit/Transport/ReplyPromise.swift +++ b/Sources/WebInspectorProxyKit/Transport/ReplyPromise.swift @@ -1,29 +1,132 @@ -package actor ReplyPromise { - private var result: Result? - private var continuations: [CheckedContinuation] +import Synchronization - package init() { - continuations = [] +package final class ReplyPromise: Sendable { + private final class Storage: Sendable { + let state = Mutex(State()) } + private enum RegistrationAction { + case wait + case resume(Result) + } + + private struct State: Sendable { + var result: Result? + var waiters: [UInt64: CheckedContinuation] = [:] + // Registration is visible before the continuation exists so an + // already-cancelled task can remove its ID without leaving a tombstone. + var registeringWaiterIDs: Set = [] + var nextWaiterID: UInt64 = 0 + } + + private let storage = Storage() + + package init() {} + + /// Returns the first terminal result, or cancels only this waiter while the + /// promise is unresolved. A terminal result linearized first is replayed. package func value() async throws -> Value { - if let result { - return try result.get() + let storage = storage + let waiterID = Self.registerWaiter(in: storage) + + return try await withTaskCancellationHandler { + try await Self.wait(storage, waiterID: waiterID) + } onCancel: { + Self.cancelWaiter(waiterID, in: storage) + } + } + + /// Waits for the terminal result after an owner has already committed to + /// cleanup. Caller cancellation cannot make external cleanup quiescent, so + /// this wait deliberately observes the reply before returning. + package func valueIgnoringCancellation() async throws -> Value { + let storage = storage + let waiterID = Self.registerWaiter(in: storage) + return try await Self.wait(storage, waiterID: waiterID) + } + + /// Stores and resumes the first terminal result. Later results are ignored. + @discardableResult + package func fulfill(_ result: Result) -> Bool { + let waiters = storage.state.withLock { state -> [CheckedContinuation]? in + guard state.result == nil else { + return nil + } + state.result = result + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + return waiters } - return try await withCheckedThrowingContinuation { continuation in - continuations.append(continuation) + guard let waiters else { + return false } + for waiter in waiters { + waiter.resume(with: result) + } + return true + } + + package func waiterCountForTesting() -> Int { + storage.state.withLock { $0.waiters.count } } - package func fulfill(_ result: Result) { - guard self.result == nil else { - return + package func bookkeepingCountForTesting() -> Int { + storage.state.withLock { $0.waiters.count + $0.registeringWaiterIDs.count } + } + + private static func registerWaiter(in storage: Storage) -> UInt64 { + storage.state.withLock { state in + precondition( + state.nextWaiterID < UInt64.max, + "ReplyPromise exhausted its waiter identifier space." + ) + state.nextWaiterID += 1 + let waiterID = state.nextWaiterID + state.registeringWaiterIDs.insert(waiterID) + return waiterID } - self.result = result - let continuations = self.continuations - self.continuations = [] - for continuation in continuations { - continuation.resume(with: result) + } + + private static func wait( + _ storage: Storage, + waiterID: UInt64 + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + let action = storage.state.withLock { state -> RegistrationAction in + guard state.registeringWaiterIDs.remove(waiterID) != nil else { + return .resume(.failure(CancellationError())) + } + if let result = state.result { + return .resume(result) + } + precondition( + state.waiters[waiterID] == nil, + "ReplyPromise registered the same waiter twice." + ) + state.waiters[waiterID] = continuation + return .wait + } + + switch action { + case .wait: + break + case let .resume(result): + continuation.resume(with: result) + } + } + } + + private static func cancelWaiter(_ waiterID: UInt64, in storage: Storage) { + let waiter = storage.state.withLock { state -> CheckedContinuation? in + if let waiter = state.waiters.removeValue(forKey: waiterID) { + return waiter + } + guard state.result == nil else { + return nil + } + state.registeringWaiterIDs.remove(waiterID) + return nil } + waiter?.resume(throwing: CancellationError()) } } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportEventSubscriberRegistry.swift b/Sources/WebInspectorProxyKit/Transport/TransportEventSubscriberRegistry.swift deleted file mode 100644 index 43454846..00000000 --- a/Sources/WebInspectorProxyKit/Transport/TransportEventSubscriberRegistry.swift +++ /dev/null @@ -1,53 +0,0 @@ -struct TransportEventSubscriberRegistry { - typealias Continuation = AsyncStream.Continuation - - private var nextSubscriberID: UInt64 = 0 - private var domainSubscribers: [ProtocolDomain: [UInt64: Continuation]] = [:] - private var orderedSubscribers: [UInt64: Continuation] = [:] - - mutating func insert(_ continuation: Continuation, domain: ProtocolDomain) -> UInt64 { - nextSubscriberID &+= 1 - let subscriberID = nextSubscriberID - domainSubscribers[domain, default: [:]][subscriberID] = continuation - return subscriberID - } - - mutating func insertOrdered(_ continuation: Continuation) -> UInt64 { - nextSubscriberID &+= 1 - let subscriberID = nextSubscriberID - orderedSubscribers[subscriberID] = continuation - return subscriberID - } - - func continuations(for domain: ProtocolDomain) -> [Continuation] { - domainSubscribers[domain].map { Array($0.values) } ?? [] - } - - var orderedContinuations: [Continuation] { - Array(orderedSubscribers.values) - } - - mutating func remove(_ subscriberID: UInt64, domain: ProtocolDomain) { - domainSubscribers[domain]?.removeValue(forKey: subscriberID) - if domainSubscribers[domain]?.isEmpty == true { - domainSubscribers.removeValue(forKey: domain) - } - } - - mutating func removeOrdered(_ subscriberID: UInt64) { - orderedSubscribers.removeValue(forKey: subscriberID) - } - - mutating func finishAndRemoveAll() { - for continuations in domainSubscribers.values { - for continuation in continuations.values { - continuation.finish() - } - } - for continuation in orderedSubscribers.values { - continuation.finish() - } - domainSubscribers.removeAll() - orderedSubscribers.removeAll() - } -} diff --git a/Sources/WebInspectorProxyKit/Transport/TransportProvisionalTargetMessageStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportProvisionalTargetMessageStore.swift index 52a80a1f..7440e14d 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportProvisionalTargetMessageStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportProvisionalTargetMessageStore.swift @@ -15,15 +15,6 @@ struct TransportProvisionalTargetMessageStore: Sendable { messagesByTargetID.removeValue(forKey: targetID) } - mutating func retargetMessages(from oldTargetID: ProtocolTarget.ID, to newTargetID: ProtocolTarget.ID) { - guard oldTargetID != newTargetID, - let messages = messagesByTargetID.removeValue(forKey: oldTargetID), - messages.isEmpty == false else { - return - } - messagesByTargetID[newTargetID, default: []].append(contentsOf: messages) - } - mutating func takeMessages(for targetID: ProtocolTarget.ID) -> [ParsedProtocolMessage] { messagesByTargetID.removeValue(forKey: targetID) ?? [] } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift index 5a6a5965..f675cf9a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReceiver.swift @@ -1,25 +1,58 @@ import Synchronization package final class TransportReceiver: Sendable { + // Swift cannot store a weak actor reference directly in this Sendable value + // state. Every read and write of this box is protected by `state`'s Mutex; + // the box never escapes the receiver. The unchecked conformance represents + // only that synchronization fact, not ownership of connection state. + private final class WeakCore: @unchecked Sendable { + weak var value: ConnectionCore? + } + + private struct QueuedMessage: Sendable { + let ordinal: UInt64 + let payload: String + } + + private struct DrainWaiter: Sendable { + let through: UInt64 + let continuation: CheckedContinuation + } + + private struct SealedWaiters: Sendable { + let drain: [DrainWaiter] + let drainRegistration: [CheckedContinuation] + } + private struct State: Sendable { - var transport: TransportSession? - var messages: [String] = [] + var core = WeakCore() + var messages: [QueuedMessage] = [] var messageStartIndex = 0 var isDraining = false var generation: UInt64 = 0 + var tailOrdinal: UInt64 = 0 + var completedOrdinal: UInt64 = 0 + var drainWaiters: [DrainWaiter] = [] + var drainWaiterRegistrationWaiters: [CheckedContinuation] = [] var isClosed = false + var closeWaiters: [CheckedContinuation] = [] + } + + private enum DrainStep: Sendable { + case deliver(core: ConnectionCore, message: QueuedMessage) + case stop(closeWaiters: [CheckedContinuation]) } private let state = Mutex(State()) package init() {} - package func setTransport(_ transport: TransportSession) { + package func setCore(_ core: ConnectionCore) { let drainGeneration = state.withLock { guard !$0.isClosed else { return nil as UInt64? } - $0.transport = transport + $0.core.value = core guard $0.messages.isEmpty == false, !$0.isDraining else { return nil } @@ -35,68 +68,274 @@ package final class TransportReceiver: Sendable { } } - package func receive(_ message: String) { - let drainGeneration = state.withLock { - guard !$0.isClosed else { - return nil as UInt64? + /// Accepts one message and returns its receiver-owned ordinal, or nil when + /// the receiver was already closed and did not accept the message. + @discardableResult + package func receive(_ message: String) -> UInt64? { + let admission = state.withLock { state in + guard !state.isClosed else { + return (ordinal: nil as UInt64?, drainGeneration: nil as UInt64?) } - $0.messages.append(message) - guard $0.transport != nil else { - return nil + precondition(state.tailOrdinal < UInt64.max, "TransportReceiver exhausted its message ordinal space.") + state.tailOrdinal += 1 + let ordinal = state.tailOrdinal + state.messages.append(QueuedMessage(ordinal: ordinal, payload: message)) + guard state.core.value != nil else { + return (ordinal: ordinal, drainGeneration: nil) } - guard !$0.isDraining else { - return nil + guard !state.isDraining else { + return (ordinal: ordinal, drainGeneration: nil) } - $0.isDraining = true - return $0.generation + state.isDraining = true + return (ordinal: ordinal, drainGeneration: state.generation) } - guard let drainGeneration else { - return + if let drainGeneration = admission.drainGeneration { + Task { + await drain(generation: drainGeneration) + } } - Task { - await drain(generation: drainGeneration) + return admission.ordinal + } + + /// Returns the ordinal of the newest message accepted by this receiver. + /// + /// A caller can snapshot this value and then await exactly that prefix + /// without waiting for messages that arrive later on the live connection. + package func tailOrdinal() -> UInt64 { + state.withLock { $0.tailOrdinal } + } + + /// Reports whether Core completed the accepted prefix through `ordinal`. + /// Completion is monotonic and remains queryable after receiver close. + package func hasCompletedDrain(through ordinal: UInt64) -> Bool { + state.withLock { $0.completedOrdinal >= ordinal } + } + + /// Suspends until `ConnectionCore` has completed every accepted message + /// through `ordinal`, or until the receiver closes. + package func waitUntilDrained(through ordinal: UInt64) async { + await withCheckedContinuation { continuation in + let result = state.withLock { state in + precondition( + ordinal <= state.tailOrdinal, + "Cannot wait for a TransportReceiver ordinal that has not been accepted." + ) + guard !state.isClosed, state.completedOrdinal < ordinal else { + return ( + shouldResume: true, + registrationWaiters: [] as [CheckedContinuation] + ) + } + state.drainWaiters.append( + DrainWaiter(through: ordinal, continuation: continuation) + ) + let registrationWaiters = state.drainWaiterRegistrationWaiters + state.drainWaiterRegistrationWaiters.removeAll(keepingCapacity: false) + return ( + shouldResume: false, + registrationWaiters: registrationWaiters + ) + } + if result.shouldResume { + continuation.resume() + } + Self.resume(result.registrationWaiters) } } - package func close() { - state.withLock { - $0.isClosed = true - $0.generation &+= 1 - $0.transport = nil - $0.messages.removeAll(keepingCapacity: false) - $0.messageStartIndex = 0 - $0.isDraining = false + package func close() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let readyWaiters = state.withLock { state in + let sealedWaiters = Self.seal(&state) + guard state.isDraining else { + return ( + close: [continuation], + sealed: sealedWaiters + ) + } + state.closeWaiters.append(continuation) + return ( + close: [] as [CheckedContinuation], + sealed: sealedWaiters + ) + } + Self.resume(readyWaiters.close) + Self.resumeSealedWaiters(readyWaiters.sealed) } } + /// Synchronous cancellation backstop for `NativeAttachment.isolated deinit`. + /// + /// Normal lifecycle code must use `close()` so it observes completion of an + /// active drain before detaching the native frontend. + package func closeSynchronously() { + let readyWaiters = state.withLock { state in + let sealedWaiters = Self.seal(&state) + return ( + close: Self.takeCloseWaitersIfQuiescent(from: &state), + sealed: sealedWaiters + ) + } + Self.resume(readyWaiters.close) + Self.resumeSealedWaiters(readyWaiters.sealed) + } + + package func closeWaiterCountForTesting() -> Int { + state.withLock { $0.closeWaiters.count } + } + + package func waitForDrainWaiterForTesting() async { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + guard !state.isClosed, state.drainWaiters.isEmpty else { + return true + } + state.drainWaiterRegistrationWaiters.append(continuation) + return false + } + if shouldResume { + continuation.resume() + } + } + } + + @discardableResult + package func fail(_ message: String) -> Task? { + let result: ( + core: ConnectionCore?, + readyCloseWaiters: [CheckedContinuation], + sealedWaiters: SealedWaiters + ) = state.withLock { state in + guard !state.isClosed else { + return (nil, [], SealedWaiters(drain: [], drainRegistration: [])) + } + let core = state.core.value + let sealedWaiters = Self.seal(&state) + return ( + core, + Self.takeCloseWaitersIfQuiescent(from: &state), + sealedWaiters + ) + } + Self.resume(result.readyCloseWaiters) + Self.resumeSealedWaiters(result.sealedWaiters) + guard let core = result.core else { + return nil + } + return core.failFromNativeCallback(message) + } + private func drain(generation: UInt64) async { - while let next = nextMessage(generation: generation) { - await next.transport.receiveRootMessage(next.message) + while true { + switch nextDrainStep(generation: generation) { + case let .deliver(core, message): + await core.receiveRootMessage(message.payload) + complete(message.ordinal) + case let .stop(closeWaiters): + Self.resume(closeWaiters) + return + } } } - private func nextMessage(generation: UInt64) -> (transport: TransportSession, message: String)? { + private func nextDrainStep(generation: UInt64) -> DrainStep { state.withLock { guard !$0.isClosed, $0.generation == generation else { - return nil + $0.isDraining = false + return .stop(closeWaiters: Self.takeCloseWaitersIfQuiescent(from: &$0)) } guard $0.messageStartIndex < $0.messages.count else { $0.messages.removeAll(keepingCapacity: true) $0.messageStartIndex = 0 $0.isDraining = false - return nil + return .stop(closeWaiters: Self.takeCloseWaitersIfQuiescent(from: &$0)) } - guard let transport = $0.transport else { + guard let core = $0.core.value else { $0.isDraining = false - return nil + return .stop(closeWaiters: Self.takeCloseWaitersIfQuiescent(from: &$0)) } let message = $0.messages[$0.messageStartIndex] $0.messageStartIndex += 1 compactMessagesIfNeeded(in: &$0) - return (transport, message) + return .deliver(core: core, message: message) + } + } + + private func complete(_ ordinal: UInt64) { + let readyWaiters = state.withLock { state in + precondition( + ordinal == state.completedOrdinal &+ 1, + "TransportReceiver completed messages outside FIFO order." + ) + state.completedOrdinal = ordinal + return Self.takeReadyDrainWaiters(from: &state) } + Self.resumeDrainWaiters(readyWaiters) + } + + private static func seal(_ state: inout State) -> SealedWaiters { + guard !state.isClosed else { + return SealedWaiters(drain: [], drainRegistration: []) + } + state.isClosed = true + state.generation &+= 1 + state.core.value = nil + state.messages.removeAll(keepingCapacity: false) + state.messageStartIndex = 0 + let drainWaiters = state.drainWaiters + state.drainWaiters.removeAll(keepingCapacity: false) + let drainWaiterRegistrationWaiters = state.drainWaiterRegistrationWaiters + state.drainWaiterRegistrationWaiters.removeAll(keepingCapacity: false) + return SealedWaiters( + drain: drainWaiters, + drainRegistration: drainWaiterRegistrationWaiters + ) + } + + private static func takeReadyDrainWaiters( + from state: inout State + ) -> [DrainWaiter] { + var pending: [DrainWaiter] = [] + var ready: [DrainWaiter] = [] + for waiter in state.drainWaiters { + if waiter.through <= state.completedOrdinal { + ready.append(waiter) + } else { + pending.append(waiter) + } + } + state.drainWaiters = pending + return ready + } + + private static func takeCloseWaitersIfQuiescent( + from state: inout State + ) -> [CheckedContinuation] { + guard state.isClosed, !state.isDraining else { + return [] + } + let waiters = state.closeWaiters + state.closeWaiters.removeAll(keepingCapacity: false) + return waiters + } + + private static func resume(_ waiters: [CheckedContinuation]) { + for waiter in waiters { + waiter.resume() + } + } + + private static func resumeDrainWaiters(_ waiters: [DrainWaiter]) { + for waiter in waiters { + waiter.continuation.resume() + } + } + + private static func resumeSealedWaiters(_ waiters: SealedWaiters) { + resumeDrainWaiters(waiters.drain) + resume(waiters.drainRegistration) } private func compactMessagesIfNeeded(in state: inout State) { diff --git a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift index 3d9e870a..3ce93d3a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportReplyStore.swift @@ -2,14 +2,193 @@ import Foundation extension TransportSession { struct PendingReply: Sendable { - var domain: ProtocolDomain - var method: String - var targetID: ProtocolTarget.ID? - var promise: ReplyPromise + enum Purpose: Equatable, Sendable { + case direct( + bindingGeneration: WebInspectorPage.Generation?, + documentEpoch: ModelDocumentEpoch? + ) + case elementPickerMode( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + documentEpoch: ModelDocumentEpoch, + enabled: Bool + ) + case modelCommand( + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) + case capability( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) + case capabilityAuxiliary( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) + case modelBootstrap( + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + targetID: ProtocolTarget.ID, + documentEpoch: ModelDocumentEpoch, + operationID: UInt64 + ) + } + + let purpose: Purpose + let domain: ProtocolDomain + let method: String + let targetID: ProtocolTarget.ID? + let promise: ReplyPromise var hasBufferedProvisionalResponse: Bool + + private init( + purpose: Purpose, + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise + ) { + self.purpose = purpose + self.domain = domain + self.method = method + self.targetID = targetID + self.promise = promise + hasBufferedProvisionalResponse = false + } + + static func direct( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise, + bindingGeneration: WebInspectorPage.Generation?, + documentEpoch: ModelDocumentEpoch? + ) -> PendingReply { + PendingReply( + purpose: .direct( + bindingGeneration: bindingGeneration, + documentEpoch: documentEpoch + ), + domain: domain, + method: method, + targetID: targetID, + promise: promise + ) + } + + static func modelCommand( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise, + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) -> PendingReply { + PendingReply( + purpose: .modelCommand( + authorization: authorization, + operationID: operationID + ), + domain: domain, + method: method, + targetID: targetID, + promise: promise + ) + } + + static func elementPickerMode( + targetID: ProtocolTarget.ID, + promise: ReplyPromise, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + documentEpoch: ModelDocumentEpoch, + enabled: Bool + ) -> PendingReply { + PendingReply( + purpose: .elementPickerMode( + key: key, + generation: generation, + documentEpoch: documentEpoch, + enabled: enabled + ), + domain: .dom, + method: "DOM.setInspectModeEnabled", + targetID: targetID, + promise: promise + ) + } + + static func capability( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID, + promise: ReplyPromise, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) -> PendingReply { + PendingReply( + purpose: .capability( + key: key, + generation: generation, + operationID: operationID + ), + domain: domain, + method: method, + targetID: targetID, + promise: promise + ) + } + + static func capabilityAuxiliary( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID, + promise: ReplyPromise, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) -> PendingReply { + PendingReply( + purpose: .capabilityAuxiliary( + key: key, + generation: generation, + operationID: operationID + ), + domain: domain, + method: method, + targetID: targetID, + promise: promise + ) + } + + static func modelBootstrap( + targetID: ProtocolTarget.ID, + promise: ReplyPromise, + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + documentEpoch: ModelDocumentEpoch, + operationID: UInt64 + ) -> PendingReply { + PendingReply( + purpose: .modelBootstrap( + feedID: feedID, + generation: generation, + targetID: targetID, + documentEpoch: documentEpoch, + operationID: operationID + ), + domain: .dom, + method: "DOM.getDocument", + targetID: targetID, + promise: promise + ) + } } - enum PendingKey: Sendable { + enum PendingKey: Hashable, Sendable { case root(UInt64) case target(ReplyKey) } @@ -35,10 +214,27 @@ struct TransportReplyStore: Sendable { } var pendingReplies: [TransportSession.PendingReply] { - Array(rootReplies.values) + targetReplies.values.map(\.pending) + Array(pendingReplyRecords.values) + } + + var pendingReplyRecords: [TransportSession.PendingKey: TransportSession.PendingReply] { + var records = Dictionary(uniqueKeysWithValues: rootReplies.map { commandID, pending in + (TransportSession.PendingKey.root(commandID), pending) + }) + for (key, record) in targetReplies { + let pendingKey = TransportSession.PendingKey.target(key) + precondition(records[pendingKey] == nil, "A pending reply has duplicate routing ownership.") + records[pendingKey] = record.pending + } + return records + } + + var pendingReplyPurposes: [TransportSession.PendingKey: TransportSession.PendingReply.Purpose] { + pendingReplyRecords.mapValues(\.purpose) } mutating func insertRootReply(_ pending: TransportSession.PendingReply, commandID: UInt64) { + precondition(rootReplies[commandID] == nil, "A root command identifier already owns a pending reply.") rootReplies[commandID] = pending } @@ -47,15 +243,15 @@ struct TransportReplyStore: Sendable { key: TransportSession.ReplyKey, rootWrapperID: UInt64 ) { - if let existingKey = targetReplyKeysByCommandID[key.commandID] { - _ = removeTargetReply(for: existingKey) - } - if let existingKey = targetReplyKeysByRootWrapperID[rootWrapperID] { - _ = removeTargetReply(for: existingKey) - } - if let existingRecord = targetReplies.removeValue(forKey: key) { - removeIndexes(for: key, record: existingRecord) - } + precondition(targetReplies[key] == nil, "A target reply key already owns a pending reply.") + precondition( + targetReplyKeysByCommandID[key.commandID] == nil, + "A target command identifier already owns a pending reply." + ) + precondition( + targetReplyKeysByRootWrapperID[rootWrapperID] == nil, + "A target wrapper identifier already owns a pending reply." + ) let record = TargetReplyRecord(pending: pending, rootWrapperID: rootWrapperID) targetReplies[key] = record @@ -67,10 +263,13 @@ struct TransportReplyStore: Sendable { } mutating func takeTargetReplyKey(forRootWrapperID rootWrapperID: UInt64) -> TransportSession.ReplyKey? { - guard let key = targetReplyKeysByRootWrapperID.removeValue(forKey: rootWrapperID), - targetReplies[key]?.rootWrapperID == rootWrapperID else { + guard let key = targetReplyKeysByRootWrapperID.removeValue(forKey: rootWrapperID) else { return nil } + precondition( + targetReplies[key]?.rootWrapperID == rootWrapperID, + "A target wrapper index does not match its pending reply owner." + ) targetReplies[key]?.rootWrapperID = nil return key } @@ -83,12 +282,15 @@ struct TransportReplyStore: Sendable { return record.pending } - mutating func removePendingReply(_ key: TransportSession.PendingKey) { + @discardableResult + mutating func removePendingReply( + _ key: TransportSession.PendingKey + ) -> TransportSession.PendingReply? { switch key { case let .root(commandID): rootReplies.removeValue(forKey: commandID) case let .target(targetReplyKey): - _ = removeTargetReply(for: targetReplyKey) + removeTargetReply(for: targetReplyKey) } } @@ -98,6 +300,17 @@ struct TransportReplyStore: Sendable { .compactMap { removeTargetReply(for: $0) } } + mutating func removePendingReplies( + where shouldRemove: (TransportSession.PendingReply) -> Bool + ) -> [TransportSession.PendingReply] { + pendingReplyRecords.compactMap { key, pending in + guard shouldRemove(pending) else { + return nil + } + return removePendingReply(key) + } + } + mutating func removeTargetReplyForTimeout(_ key: TransportSession.ReplyKey) -> TransportSession.PendingReply? { if let record = targetReplies[key] { guard !record.pending.hasBufferedProvisionalResponse else { @@ -131,26 +344,6 @@ struct TransportReplyStore: Sendable { return removeTargetReply(for: key) } - mutating func retargetPendingReplies( - from oldTargetID: ProtocolTarget.ID, - to newTargetID: ProtocolTarget.ID - ) { - let oldKeys = targetReplies.keys.filter { $0.targetID == oldTargetID } - for oldKey in oldKeys { - guard var record = targetReplies.removeValue(forKey: oldKey) else { - continue - } - removeIndexes(for: oldKey, record: record) - let newKey = TransportSession.ReplyKey(targetID: newTargetID, commandID: oldKey.commandID) - if let existingRecord = targetReplies.removeValue(forKey: newKey) { - removeIndexes(for: newKey, record: existingRecord) - } - record.pending.targetID = newTargetID - targetReplies[newKey] = record - insertIndexes(for: newKey, record: record) - } - } - mutating func removeAll() { rootReplies.removeAll() targetReplies.removeAll() @@ -160,8 +353,16 @@ struct TransportReplyStore: Sendable { private mutating func insertIndexes(for key: TransportSession.ReplyKey, record: TargetReplyRecord) { if let rootWrapperID = record.rootWrapperID { + precondition( + targetReplyKeysByRootWrapperID[rootWrapperID] == nil, + "A target wrapper index already has an owner." + ) targetReplyKeysByRootWrapperID[rootWrapperID] = key } + precondition( + targetReplyKeysByCommandID[key.commandID] == nil, + "A target command index already has an owner." + ) targetReplyKeysByCommandID[key.commandID] = key } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift index 9d7eddc9..384cfd8a 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportSession.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportSession.swift @@ -1,214 +1,3729 @@ import Foundation +import Synchronization -package actor TransportSession { +private struct ConnectionPendingReplyOwnership: Equatable, Sendable { + let key: TransportSession.PendingKey + let purpose: TransportSession.PendingReply.Purpose +} + +private struct ConnectionOwnedCommandOperation: Sendable { + let backend: any TransportBackend + let message: String + let promise: ReplyPromise + let pendingReplyOwnership: ConnectionPendingReplyOwnership + let timeoutAction: (@Sendable () async -> Void)? + + func result() async throws -> ProtocolCommand.Result { + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await backend.sendJSONString(message) + try Task.checkCancellation() + + let timeoutTask = timeoutAction.map { action in + Task { + await action() + } + } + defer { + timeoutTask?.cancel() + } + return try await promise.value() + } onCancel: { + promise.fulfill(.failure(CancellationError())) + } + } + + func value() async throws { + _ = try await result() + } +} + +private struct ConnectionDirectCommandAdmission: Sendable { + let bindingGeneration: WebInspectorPage.Generation? + let documentEpoch: ModelDocumentEpoch? +} + +private enum ConnectionTargetCommandOwner: Sendable { + case direct(ConnectionDirectCommandAdmission) + case elementPickerMode( + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + documentEpoch: ModelDocumentEpoch, + enabled: Bool + ) +} + +private enum ConnectionModelCommandFailureOverride: Sendable { + case staleIdentifier + case notActive + case cancelled + case terminal(TransportSession.Error) + + var error: any Swift.Error { + switch self { + case .staleIdentifier: + WebInspectorProxyError.staleIdentifier + case .notActive: + ConnectionModelCommandError.notActive + case .cancelled: + CancellationError() + case let .terminal(error): + error + } + } +} + +private final class ConnectionModelCommandControl: Sendable { + private struct State: Sendable { + var task: Task? + var failure: ConnectionModelCommandFailureOverride? + } + + private let state = Mutex(State()) + + func install(_ task: Task) { + let shouldCancel = state.withLock { state in + precondition(state.task == nil, "A model command installed more than one runner task.") + state.task = task + return state.failure != nil + } + if shouldCancel { + task.cancel() + } + } + + func cancelByCaller() { + let task = state.withLock { state in + if state.failure == nil { + state.failure = .cancelled + } + return state.task + } + task?.cancel() + } + + func failFromOwner(_ failure: ConnectionModelCommandFailureOverride) { + let task = state.withLock { state in + switch state.failure { + case nil, .some(.cancelled): + state.failure = failure + case .some: + break + } + return state.task + } + task?.cancel() + } + + var task: Task? { + state.withLock { $0.task } + } + + var failure: ConnectionModelCommandFailureOverride? { + state.withLock { $0.failure } + } + + func resolve( + _ fallback: Result + ) -> Result { + if let failure { + return .failure(failure.error) + } + return fallback + } +} + +private struct ConnectionModelCommandOperation: Sendable { + let task: Task + let control: ConnectionModelCommandControl +} + +private struct ConnectionModelCommandTask: Sendable { + let control: ConnectionModelCommandControl + let authorization: ConnectionModelCommandAuthorization + let domain: ProtocolDomain + let method: String + let routing: ProtocolCommand.Routing + var pendingReplyOwnership: ConnectionPendingReplyOwnership? + var readinessSignal: ReplyPromise? +} + +private enum ConnectionModelCommandReadiness: Sendable { + case ready(targetID: ProtocolTarget.ID?) + case waiting +} + +private enum ConnectionModelCommandStep: Sendable { + case waiting(ReplyPromise) + case ready(ConnectionOwnedCommandOperation) +} + +private enum ConnectionPendingReplyFailureReason: Sendable { + case staleIdentifier + case missingTarget(ProtocolTarget.ID) + case modelFeedNotActive + + var error: any Swift.Error { + switch self { + case .staleIdentifier: + WebInspectorProxyError.staleIdentifier + case let .missingTarget(targetID): + TransportSession.Error.missingTarget(targetID) + case .modelFeedNotActive: + ConnectionModelCommandError.notActive + } + } +} + +private struct ConnectionPendingReplyFailure: Sendable { + let pending: TransportSession.PendingReply + let reason: ConnectionPendingReplyFailureReason +} + +private struct ConnectionCommandInvalidationEffects: Sendable { + var pendingFailures: [ConnectionPendingReplyFailure] = [] + var modelCommandTasksToAwait: [Task] = [] +} + +private struct ConnectionCapabilityTask: Sendable { + let task: Task + var pendingReplyOwnership: ConnectionPendingReplyOwnership? +} + +private struct ConnectionModelBootstrapTask: Sendable { + let task: Task + let pendingReplyOwnership: ConnectionPendingReplyOwnership + let feedID: ConnectionModelFeedID + let generation: WebInspectorPage.Generation +} + +private struct PhysicalTargetDisappearanceWaiters: Sendable { + var activation: [ReplyPromise] = [] + var release: [ReplyPromise] = [] +} + +private struct ConnectionModelFeedCapabilityLease: Sendable { + let owner: ConnectionCapabilityLeaseOwner + let key: ConnectionCapabilityKey +} + +private struct ConnectionModelFeedSynchronizationState: Sendable { + let generation: WebInspectorPage.Generation + var completedDomains: Set = [] + var didPublish = false +} + +private struct ConnectionDOMBootstrapState: Sendable { + enum ReplyDisposition: Equatable, Sendable { + case published + case stale + case terminal + } + + struct TargetState: Sendable { + let target: ModelTarget + var completedEpoch: ModelDocumentEpoch? + } + + struct ActiveOperation: Sendable { + let id: UInt64 + let targetID: ProtocolTarget.ID + let documentEpoch: ModelDocumentEpoch + var replyDisposition: ReplyDisposition? + } + + let generation: WebInspectorPage.Generation + var orderedTargetIDs: [ProtocolTarget.ID] + var targetsByID: [ProtocolTarget.ID: TargetState] + var activeOperation: ActiveOperation? + var needsCompletionMarker: Bool +} + +private enum ConnectionModelFeedLifecycle: Sendable { + case acquiring + case active + case rollingBack + case closing(ReplyPromise) +} + +private struct ConnectionModelFeedRegistration: Sendable { + let id: ConnectionModelFeedID + let configuredDomains: Set + let mailbox: ConnectionModelFeedMailbox + var lifecycle: ConnectionModelFeedLifecycle + var capabilityLeases: [ConnectionModelFeedCapabilityLease] + var elementPickerLease: ConnectionModelFeedCapabilityLease? + var targetSnapshotThrough: UInt64? + var resetGeneration: WebInspectorPage.Generation + var synchronization: ConnectionModelFeedSynchronizationState? + var domBootstrap: ConnectionDOMBootstrapState? +} + +private struct CurrentPageBindingChangeEffects: Sendable { + var capabilityKeysToReconcile: [ConnectionCapabilityKey] = [] + var releaseWaiters: [ReplyPromise] = [] + var modelBootstrapTasksToAwait: [Task] = [] + var commandInvalidation = ConnectionCommandInvalidationEffects() +} + +private struct RootEventMutation: Sendable { + var pendingStyleSheetEvents: [ResolvedStyleSheetAddedEvent] = [] + var bindingEffects = CurrentPageBindingChangeEffects() + var physicalTargetWaiters = PhysicalTargetDisappearanceWaiters() + var commandInvalidation = ConnectionCommandInvalidationEffects() +} + +private struct MainPageTargetNotification: Sendable { + let waiters: [ReplyPromise] + let result: TransportSession.MainPageTarget +} + +private struct EventEmissionEffects: Sendable { + var mainPageTargetNotification: MainPageTargetNotification? + var commandInvalidation = ConnectionCommandInvalidationEffects() +} + +private struct ConnectionElementPickerMode: Sendable { + enum Physical: Sendable { + case inactive(WebInspectorPage.Generation) + case enabling(WebInspectorPage.Generation) + case enabled(WebInspectorPage.Generation) + case disabling(WebInspectorPage.Generation) + + var generation: WebInspectorPage.Generation { + switch self { + case let .inactive(generation), + let .enabling(generation), + let .enabled(generation), + let .disabling(generation): + generation + } + } + } + + var owners: Set + var activatedThrough: [ConnectionCapabilityLeaseOwner: UInt64] + var activationWaiters: [ + ConnectionCapabilityLeaseOwner: ReplyPromise + ] + var releaseWaiters: [ + ConnectionCapabilityLeaseOwner: ReplyPromise + ] + var physical: Physical + + init(generation: WebInspectorPage.Generation) { + owners = [] + activatedThrough = [:] + activationWaiters = [:] + releaseWaiters = [:] + physical = .inactive(generation) + } +} + +/// Owns one physical inspector connection. +/// +/// Target membership, command/reply routing, inbound ordering, and terminal +/// state deliberately live on the same actor so no public handle has to mirror +/// transport state in order to stay current. +package actor ConnectionCore { package typealias TimeoutSleep = @Sendable (Duration) async throws -> Void package typealias ResponseTimeoutDidFire = @Sendable () async -> Void + package typealias CloseAction = @Sendable () async -> Void + package typealias MessageParser = @Sendable (String) async throws -> ParsedProtocolMessage + + package enum TerminalCause: Equatable, Sendable { + case explicitClose + case fatal(String) + case protocolViolation(String) + case modelFeedFailure(ConnectionModelFeedError) + } + + private enum State { + case open + case closing + case closed + } + + private final class TerminalClaim: Sendable { + struct Result: Sendable { + let cause: TerminalCause + let claimedProposedCause: Bool + } + + private let cause = Mutex(nil) + + func claim(_ proposedCause: TerminalCause) -> Result { + cause.withLock { cause in + if let cause { + return Result(cause: cause, claimedProposedCause: false) + } + cause = proposedCause + return Result(cause: proposedCause, claimedProposedCause: true) + } + } + + var current: TerminalCause? { + cause.withLock { $0 } + } + } + + private struct TerminalOperation: Sendable { + let transportError: TransportSession.Error + let scopeError: WebInspectorProxyError? + let pendingReplies: [TransportSession.PendingReply] + let mainPageTargetWaiters: [ReplyPromise] + let activationWaiters: [ReplyPromise] + let releaseWaiters: [ReplyPromise] + let capabilityTasks: [Task] + let modelBootstrapTasks: [Task] + let modelCommandTasks: [Task] + let closeAction: CloseAction + + func run() async { + for pending in pendingReplies { + pending.promise.fulfill(.failure(transportError)) + } + for waiter in mainPageTargetWaiters { + waiter.fulfill(.failure(transportError)) + } + for waiter in activationWaiters { + waiter.fulfill(.failure(scopeError ?? WebInspectorProxyError.closed)) + } + for waiter in releaseWaiters { + if let scopeError { + waiter.fulfill(.failure(scopeError)) + } else { + waiter.fulfill(.success(())) + } + } + for task in capabilityTasks { + await task.value + } + for task in modelBootstrapTasks { + await task.value + } + for task in modelCommandTasks { + _ = await task.result + } + await closeAction() + } + } private let backend: any TransportBackend private let responseTimeout: Duration? private let timeoutSleep: TimeoutSleep private let responseTimeoutDidFire: ResponseTimeoutDidFire + private let messageParser: MessageParser + private nonisolated let terminalClaim: TerminalClaim private var nextCommandID: UInt64 private var eventSequences: TransportEventSequenceTracker private var replyStore: TransportReplyStore - private var mainPageTargetWaiterStore: TransportSession.MainPageTargetWaiterStore + private var mainPageTargetWaiterStore: ConnectionCore.MainPageTargetWaiterStore private var targetRegistry: TransportTargetRegistry private var provisionalTargetMessageStore: TransportProvisionalTargetMessageStore private var styleSheetRouting: TransportStyleSheetRouting private var runtimeContextRegistry: RuntimeContextRegistry - private var eventSubscribers: TransportEventSubscriberRegistry + private var eventScopes: ConnectionEventScopeRegistry + private var modelFeed: ConnectionModelFeedRegistration? + private var replayWasTaintedByDirectConsumer: Bool + private var capabilities: ConnectionCapabilityRegistry + private var elementPickerModes: [ + ConnectionCapabilityKey: ConnectionElementPickerMode + ] + private var inspectorInitializedGeneration: [ + ConnectionCapabilityKey: WebInspectorPage.Generation + ] + private var capabilityTasks: [UInt64: ConnectionCapabilityTask] + private var nextModelBootstrapOperationID: UInt64 + private var modelBootstrapTasks: [UInt64: ConnectionModelBootstrapTask] + private var nextModelCommandOperationID: UInt64 + private var modelCommandTasks: [UInt64: ConnectionModelCommandTask] + private var modelCommandOwnerCountWaiters: [( + expectedCount: Int, + continuation: CheckedContinuation + )] + private var modelCommandReadinessCountWaiters: [( + expectedCount: Int, + continuation: CheckedContinuation + )] + private var modelDocumentEpochs: [ProtocolTarget.ID: ModelDocumentEpoch] + private var currentPageGeneration: WebInspectorPage.Generation + private var currentPageBindingGapIsOpen: Bool + private var eventScopeRegistrationWaiters: [( + expectedCount: Int, + continuation: CheckedContinuation + )] + private var eventScopeActivationCancellationAction: (@Sendable () async -> Void)? private var inboundMessageQueue: TransportInboundMessageQueue - private var closed: Bool + private var closeAction: CloseAction + private var terminalTask: Task? + private var state: State + private var nextCloseWaiterID: UInt64 + private var closeWaiters: [UInt64: CheckedContinuation] + private var closeWaiterRegistrationWaiters: [CheckedContinuation] + private var cancelledCloseWaiterIDs: Set + private var modelTargetMutationActionForTesting: (@Sendable () -> Void)? package init( backend: any TransportBackend, responseTimeout: Duration? = .seconds(5), timeoutSleep: TimeoutSleep? = nil, - responseTimeoutDidFire: ResponseTimeoutDidFire? = nil + responseTimeoutDidFire: ResponseTimeoutDidFire? = nil, + messageParser: @escaping MessageParser = { + try await TransportMessageParser.parse($0) + }, + closeAction: CloseAction? = nil ) { self.backend = backend self.responseTimeout = responseTimeout self.timeoutSleep = timeoutSleep ?? { try await Task.sleep(for: $0) } self.responseTimeoutDidFire = responseTimeoutDidFire ?? {} + self.messageParser = messageParser + terminalClaim = TerminalClaim() nextCommandID = 0 eventSequences = TransportEventSequenceTracker() replyStore = TransportReplyStore() - mainPageTargetWaiterStore = TransportSession.MainPageTargetWaiterStore() + mainPageTargetWaiterStore = ConnectionCore.MainPageTargetWaiterStore() targetRegistry = TransportTargetRegistry() provisionalTargetMessageStore = TransportProvisionalTargetMessageStore() styleSheetRouting = TransportStyleSheetRouting() runtimeContextRegistry = RuntimeContextRegistry() - eventSubscribers = TransportEventSubscriberRegistry() + eventScopes = ConnectionEventScopeRegistry() + modelFeed = nil + replayWasTaintedByDirectConsumer = false + capabilities = ConnectionCapabilityRegistry() + elementPickerModes = [:] + inspectorInitializedGeneration = [:] + capabilityTasks = [:] + nextModelBootstrapOperationID = 0 + modelBootstrapTasks = [:] + nextModelCommandOperationID = 0 + modelCommandTasks = [:] + modelCommandOwnerCountWaiters = [] + modelCommandReadinessCountWaiters = [] + modelDocumentEpochs = [:] + currentPageGeneration = WebInspectorPage.Generation(rawValue: 0) + currentPageBindingGapIsOpen = false + eventScopeRegistrationWaiters = [] + eventScopeActivationCancellationAction = nil inboundMessageQueue = TransportInboundMessageQueue() - closed = false + self.closeAction = closeAction ?? { + await backend.detach() + } + terminalTask = nil + state = .open + nextCloseWaiterID = 0 + closeWaiters = [:] + closeWaiterRegistrationWaiters = [] + cancelledCloseWaiterIDs = [] + modelTargetMutationActionForTesting = nil } - package func events(for domain: ProtocolDomain) -> AsyncStream { - guard !closed else { - return finishedStream(of: ProtocolEvent.self) + isolated deinit { + // Asynchronous detach belongs to explicit close. The isolated + // deinitializer is only a synchronous backstop for actor-owned local + // resources; native resources have their own isolated backstop. + eventScopes.finishAndRemoveAll(with: WebInspectorProxyError.closed) + modelFeed?.mailbox.finish(throwing: WebInspectorProxyError.closed) + modelFeed = nil + let modelTasks = Array(modelCommandTasks.values) + modelCommandTasks.removeAll() + for modelTask in modelTasks { + modelTask.control.failFromOwner(.terminal(.transportClosed)) + if let ownership = modelTask.pendingReplyOwnership { + _ = replyStore.removePendingReply(ownership.key) + } + } + terminalTask?.cancel() + terminalTask = nil + let tasks = Array(capabilityTasks.values) + capabilityTasks.removeAll() + for task in tasks { + task.task.cancel() + if let ownership = task.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition( + pending.purpose == ownership.purpose, + "A capability task attempted to remove a reply owned by another purpose." + ) + } } - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - let subscriberID = eventSubscribers.insert(pair.continuation, domain: domain) - pair.continuation.onTermination = { [weak self] _ in - Task { - await self?.removeSubscriber(subscriberID, domain: domain) + let bootstrapTasks = Array(modelBootstrapTasks.values) + modelBootstrapTasks.removeAll() + for task in bootstrapTasks { + task.task.cancel() + if let pending = replyStore.removePendingReply(task.pendingReplyOwnership.key) { + precondition( + pending.purpose == task.pendingReplyOwnership.purpose, + "A model bootstrap task attempted to remove a reply owned by another purpose." + ) } } - return pair.stream + precondition(replyStore.pendingReplies.isEmpty, "ConnectionCore deinitialized with pending replies; call close() explicitly.") + precondition(mainPageTargetWaiterStore.isEmpty, "ConnectionCore deinitialized with pending target waiters; call close() explicitly.") + precondition(closeWaiters.isEmpty, "ConnectionCore deinitialized with pending close waiters.") + precondition(capabilities.states.values.allSatisfy { $0.activationWaiters.isEmpty && $0.releaseWaiters.isEmpty }, "ConnectionCore deinitialized with pending capability waiters.") + precondition(eventScopeRegistrationWaiters.isEmpty, "ConnectionCore deinitialized with event-scope test waiters.") + for waiter in modelCommandOwnerCountWaiters { + waiter.continuation.resume() + } + modelCommandOwnerCountWaiters.removeAll() + for waiter in modelCommandReadinessCountWaiters { + waiter.continuation.resume() + } + modelCommandReadinessCountWaiters.removeAll() + } + + private var isOpen: Bool { + guard case .open = state else { + return false + } + return terminalClaim.current == nil + } + + private var claimedTerminalCause: TerminalCause { + guard let cause = terminalClaim.current else { + preconditionFailure("ConnectionCore entered terminal state without a claim.") + } + return cause } - package func orderedEvents() -> AsyncStream { - guard !closed else { - return finishedStream(of: ProtocolEvent.self) + package func openModelFeed( + configuredDomains: Set, + onRegistered: (@Sendable (ConnectionModelFeed) async -> Bool)? = nil + ) async throws -> ConnectionModelFeed { + guard isOpen else { + throw terminalScopeError + } + guard modelFeed == nil else { + throw ConnectionModelFeedError.alreadyOpen + } + guard !replayWasTaintedByDirectConsumer else { + throw ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer + } + + let configuredDomains = ModelDomain.normalized(configuredDomains) + let id = ConnectionModelFeedID() + let mailbox = ConnectionModelFeedMailbox() + let feed = ConnectionModelFeed(id: id, owner: self, mailbox: mailbox) + let resetGeneration: WebInspectorPage.Generation + if targetRegistry.currentMainPageTargetID != nil || currentPageBindingGapIsOpen { + resetGeneration = currentPageGeneration + } else { + resetGeneration = WebInspectorPage.Generation( + rawValue: currentPageGeneration.rawValue &+ 1 + ) + } + modelFeed = ConnectionModelFeedRegistration( + id: id, + configuredDomains: configuredDomains, + mailbox: mailbox, + lifecycle: .acquiring, + capabilityLeases: [], + elementPickerLease: nil, + targetSnapshotThrough: nil, + resetGeneration: resetGeneration, + synchronization: nil, + domBootstrap: nil + ) + + guard enqueueModelFeedRecord(.reset(resetGeneration)) else { + throw ConnectionModelFeedError.consumerTerminated } - let pair = AsyncStream.makeStream(bufferingPolicy: .unbounded) - let subscriberID = eventSubscribers.insertOrdered(pair.continuation) - pair.continuation.onTermination = { [weak self] _ in - Task { - await self?.removeOrderedSubscriber(subscriberID) + if targetRegistry.currentMainPageTargetID != nil, + !publishModelTargetSnapshot() { + throw ConnectionModelFeedError.consumerTerminated + } + + do { + if let onRegistered, + !(await onRegistered(feed)) { + throw ConnectionModelFeedError.consumerTerminated + } + for domain in ModelDomain.ordered(configuredDomains) { + do { + let leaseOwner = ConnectionCapabilityLeaseOwner.modelFeed(id, domain) + let capabilityDomains = ConnectionCapabilityActivationPlan.domains( + for: domain.capabilityDependencies, + includePageDependencyForCSS: true + ) + for capabilityDomain in capabilityDomains { + try Task.checkCancellation() + guard isOpen else { + throw terminalScopeError + } + let key = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: capabilityDomain + ) + let lease = ConnectionModelFeedCapabilityLease( + owner: leaseOwner, + key: key + ) + let activation = beginCapabilityLease( + leaseOwner, + for: key, + generation: currentPageGeneration + ) + appendModelFeedCapabilityLease(lease, feedID: id) + try await activateCapabilityLease( + leaseOwner, + for: key, + activation: activation + ) + } + } catch { + throw Self.modelFeedActivationError(error, domain: domain) + } + } + try Task.checkCancellation() + guard isOpen else { + throw terminalScopeError + } + guard let registration = modelFeed, + registration.id == id else { + preconditionFailure("A model feed lost its exclusive registration during acquisition.") + } + guard case .acquiring = registration.lifecycle else { + preconditionFailure("A model feed changed lifecycle before acquisition completed.") + } + guard publishModelSynchronizationIfReady( + allowWhileAcquiring: true + ) else { + throw ConnectionModelFeedError.consumerTerminated + } + guard var synchronizedRegistration = modelFeed, + synchronizedRegistration.id == id, + case .acquiring = synchronizedRegistration.lifecycle else { + preconditionFailure("A model feed changed lifecycle while publishing readiness.") } + synchronizedRegistration.lifecycle = .active + modelFeed = synchronizedRegistration + reevaluateModelCommandReadinessWaiters() + return feed + } catch { + let cleanupError = await rollbackModelFeedAcquisition(id) + let resultError: any Swift.Error + if let cleanupError { + resultError = WebInspectorScopeError( + operationError: error, + cleanupError: cleanupError + ) + } else { + resultError = error + } + mailbox.poison(throwing: resultError) + throw resultError } - return pair.stream } - package func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { - try Task.checkCancellation() - guard !closed else { - throw TransportSession.Error.transportClosed + package func closeModelFeed(_ id: ConnectionModelFeedID) async throws { + guard var registration = modelFeed, + registration.id == id else { + return + } + switch registration.lifecycle { + case .acquiring: + preconditionFailure("A model feed cannot close before openModelFeed returns.") + case .rollingBack: + preconditionFailure("A model feed cannot close while its failed open is rolling back.") + case .closing(let completion): + return try await completion.valueIgnoringCancellation() + case .active: + break } - switch command.routing { - case .root: - return try await sendRoot(command) - case let .target(targetID): - guard targetRegistry.containsTarget(targetID) else { - throw TransportSession.Error.missingTarget(targetID) + let completion = ReplyPromise() + let elementPickerLease = registration.elementPickerLease + registration.elementPickerLease = nil + registration.lifecycle = .closing(completion) + modelFeed = registration + let commandInvalidation = invalidateModelCommands( + where: { $0.authorization.feedID == id }, + failureOverride: .notActive, + pendingFailureReason: .modelFeedNotActive + ) + await completeCommandInvalidationEffects(commandInvalidation) + await cancelAndAwaitModelBootstrapTasks(feedID: id) + + var cleanupError: (any Swift.Error)? + if let elementPickerLease { + if let error = await releaseElementPickerResources( + elementPickerLease.owner, + for: elementPickerLease.key + ) { + cleanupError = error + registration.mailbox.poison(throwing: error) } - if let result = transportLocalResult(for: command, targetID: targetID) { - return result + } + for lease in registration.capabilityLeases.reversed() { + do { + try await releaseCapabilityLease(lease.owner, for: lease.key) + } catch { + if cleanupError == nil { + cleanupError = error + registration.mailbox.poison(throwing: error) + } } - return try await sendTarget(command, targetID: targetID) - case let .octopus(pageTarget): - let resolvedTarget = try pageTarget ?? currentMainPageTarget() - guard targetRegistry.containsTarget(resolvedTarget) else { - throw TransportSession.Error.missingTarget(resolvedTarget) + } + + if let cleanupError { + await terminateForModelFeedCapabilityCleanupFailure(cleanupError) + completion.fulfill(.failure(cleanupError)) + throw cleanupError + } + + guard isOpen else { + do { + try await waitUntilClosed() + completion.fulfill(.success(())) + return + } catch { + completion.fulfill(.failure(error)) + throw error + } + } + guard let currentRegistration = modelFeed, + currentRegistration.id == id else { + preconditionFailure("A closing model feed lost its exclusive registration.") + } + modelFeed = nil + currentRegistration.mailbox.finish() + completion.fulfill(.success(())) + } + + private func appendModelFeedCapabilityLease( + _ lease: ConnectionModelFeedCapabilityLease, + feedID: ConnectionModelFeedID + ) { + guard var registration = modelFeed, + registration.id == feedID else { + preconditionFailure("A model feed lost its registration while acquiring a capability.") + } + guard case .acquiring = registration.lifecycle else { + preconditionFailure("Only an acquiring model feed can add capability leases.") + } + precondition( + !registration.capabilityLeases.contains { + $0.owner == lease.owner && $0.key == lease.key + }, + "A model feed attempted to acquire the same capability lease twice." + ) + registration.capabilityLeases.append(lease) + modelFeed = registration + } + + package func acquireModelFeedElementPicker( + _ feedID: ConnectionModelFeedID + ) async throws { + guard var registration = modelFeed, + registration.id == feedID, + case .active = registration.lifecycle else { + throw ConnectionModelCommandError.notActive + } + guard registration.configuredDomains.contains(.dom) else { + throw ConnectionModelCommandError.domainNotConfigured(.dom) + } + guard registration.elementPickerLease == nil else { + return + } + + let leaseOwner = ConnectionCapabilityLeaseOwner.modelElementPicker(feedID) + let key = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: .inspector + ) + let lease = ConnectionModelFeedCapabilityLease( + owner: leaseOwner, + key: key + ) + let activation = beginCapabilityLease( + leaseOwner, + for: key, + generation: currentPageGeneration + ) + registration.elementPickerLease = lease + modelFeed = registration + + do { + try await activateCapabilityLease( + leaseOwner, + for: key, + activation: activation + ) + try await acquireElementPickerMode(leaseOwner, for: key) + } catch { + let operationError = error + if var current = modelFeed, + current.id == feedID, + current.elementPickerLease?.owner == leaseOwner { + current.elementPickerLease = nil + modelFeed = current + } + if let cleanupError = await releaseElementPickerResources( + leaseOwner, + for: key + ) { + throw WebInspectorScopeError( + operationError: operationError, + cleanupError: cleanupError + ) + } + throw operationError + } + + guard let current = modelFeed, + current.id == feedID, + case .active = current.lifecycle, + current.elementPickerLease?.owner == leaseOwner else { + if let cleanupError = await releaseElementPickerResources( + leaseOwner, + for: key + ) { + throw cleanupError + } + throw ConnectionModelCommandError.notActive + } + } + + package func releaseModelFeedElementPicker( + _ feedID: ConnectionModelFeedID + ) async throws { + guard var registration = modelFeed, + registration.id == feedID, + let lease = registration.elementPickerLease else { + return + } + registration.elementPickerLease = nil + modelFeed = registration + if let error = await releaseElementPickerResources( + lease.owner, + for: lease.key + ) { + registration.mailbox.poison(throwing: error) + await terminateForModelFeedCapabilityCleanupFailure(error) + throw error + } + } + + private func releaseElementPickerResources( + _ owner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async -> (any Swift.Error)? { + let modeResult: Result + do { + try await releaseElementPickerMode(owner, for: key) + modeResult = .success(()) + } catch { + modeResult = .failure(error) + } + let capabilityResult: Result + do { + try await releaseCapabilityLease(owner, for: key) + capabilityResult = .success(()) + } catch { + capabilityResult = .failure(error) + } + if case .failure = modeResult, + case .success = capabilityResult { + markElementPickerModeInactive(for: key) + } + switch (modeResult, capabilityResult) { + case (.success, .success): + return nil + case let (.failure(modeError), .success): + return modeError + case let (.success, .failure(capabilityError)): + return capabilityError + case let (.failure(modeError), .failure(capabilityError)): + return WebInspectorScopeError( + operationError: modeError, + cleanupError: capabilityError + ) + } + } + + private func rollbackModelFeedAcquisition( + _ id: ConnectionModelFeedID + ) async -> (any Swift.Error)? { + guard var registration = modelFeed, + registration.id == id else { + return await modelFeedTerminalErrorIfNeeded() + } + guard case .acquiring = registration.lifecycle else { + preconditionFailure("Only an acquiring model feed can roll back its capabilities.") + } + registration.lifecycle = .rollingBack + modelFeed = registration + let commandInvalidation = invalidateModelCommands( + where: { $0.authorization.feedID == id }, + failureOverride: .notActive, + pendingFailureReason: .modelFeedNotActive + ) + await completeCommandInvalidationEffects(commandInvalidation) + await cancelAndAwaitModelBootstrapTasks(feedID: id) + var cleanupError: (any Swift.Error)? + for lease in registration.capabilityLeases.reversed() { + do { + try await releaseCapabilityLease(lease.owner, for: lease.key) + } catch { + if cleanupError == nil { + cleanupError = error + registration.mailbox.poison(throwing: error) + } + } + } + if let cleanupError { + await terminateForModelFeedCapabilityCleanupFailure(cleanupError) + return cleanupError + } + if let terminalError = await modelFeedTerminalErrorIfNeeded() { + return terminalError + } + guard let currentRegistration = modelFeed, + currentRegistration.id == id else { + guard !isOpen else { + preconditionFailure( + "A model feed lost its registration before rollback completed." + ) } - if let result = transportLocalResult(for: command, targetID: resolvedTarget) { - return result + // Explicit connection close owns terminal feed retirement and has + // already finished the mailbox. A fatal terminal cause returned + // its error above; normal close therefore completes rollback. + return nil + } + modelFeed = nil + return nil + } + + private func modelFeedTerminalErrorIfNeeded() async -> (any Swift.Error)? { + guard !isOpen else { + return nil + } + do { + try await waitUntilClosed() + return nil + } catch { + return error + } + } + + private func terminateForModelFeedCapabilityCleanupFailure( + _ error: any Swift.Error + ) async { + if isOpen { + await terminate(.fatal( + "Failed to release model feed capabilities: \(error)" + )) + } else { + _ = await modelFeedTerminalErrorIfNeeded() + } + } + + private func cancelAndAwaitModelBootstrapTasks( + feedID: ConnectionModelFeedID + ) async { + if var registration = modelFeed, + registration.id == feedID { + registration.domBootstrap = nil + modelFeed = registration + } + let tasks = modelBootstrapTasks.values.filter { $0.feedID == feedID } + for task in tasks { + task.task.cancel() + } + for task in tasks { + await task.task.value + } + precondition( + modelBootstrapTasks.values.allSatisfy { $0.feedID != feedID }, + "A model feed stopped before its DOM bootstrap tasks completed." + ) + } + + package func pageGeneration() throws -> WebInspectorPage.Generation { + guard isOpen else { + throw terminalScopeError + } + guard targetRegistry.currentMainPageTargetID != nil else { + throw WebInspectorProxyError.pageUnavailable + } + return currentPageGeneration + } + + package func acquireEventScope( + route: RoutingTargetID, + targetID: WebInspectorTarget.ID, + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) async throws -> WebInspectorProxyEventScope { + guard isOpen else { + throw terminalScopeError + } + try requireAvailableTarget(for: route) + try claimDirectConsumer() + + let capacity = buffering.capacity + let mailbox = WebInspectorEventMailbox(capacity: capacity) + let stream = mailbox.makeStream() + + let scopeID = WebInspectorProxyEventScopeID() + let generation = generation(for: route) + let sink = WebInspectorEventSink( + id: scopeID, + route: route, + targetID: targetID, + domain: domain, + mailbox: mailbox, + extract: extract + ) + + // Registration and the initial generation marker happen before the + // logical lease can send its first wire enable command. + eventScopes.insert( + sink, + capacity: capacity, + generation: generation + ) + resumeEventScopeRegistrationWaitersIfNeeded() + + let leaseOwner = ConnectionCapabilityLeaseOwner.eventScope(scopeID) + + do { + let capabilityDomains = ConnectionCapabilityActivationPlan.domains( + for: [domain], + includePageDependencyForCSS: cssRequiresPageCapability(for: route) + ) + for capabilityDomain in capabilityDomains { + let key = ConnectionCapabilityKey( + route: route, + targetID: targetID, + domain: capabilityDomain + ) + let activation = beginCapabilityLease( + leaseOwner, + for: key, + generation: generation + ) + eventScopes.appendCapability(key, to: scopeID) + try await activateCapabilityLease( + leaseOwner, + for: key, + activation: activation + ) + } + if domain == .inspector { + let key = ConnectionCapabilityKey( + route: route, + targetID: targetID, + domain: domain + ) + try await acquireElementPickerMode(leaseOwner, for: key) + } + } catch { + let operationError = error + do { + try await releaseEventScope(scopeID) + } catch { + throw WebInspectorScopeError( + operationError: operationError, + cleanupError: error + ) + } + throw operationError + } + + return WebInspectorProxyEventScope(id: scopeID, events: stream) + } + + package func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws { + guard let entry = eventScopes.remove(id) else { + return + } + resumeEventScopeRegistrationWaitersIfNeeded() + entry.sink?.finish(nil) + + let owner = ConnectionCapabilityLeaseOwner.eventScope(id) + if let inspectorKey = entry.capabilities.last, + inspectorKey.domain == .inspector { + if let error = await releaseElementPickerResources(owner, for: inspectorKey) { + throw error + } + return + } + var cleanupError: (any Swift.Error)? + for key in entry.capabilities.reversed() { + do { + try await releaseCapabilityLease(owner, for: key) + } catch { + if cleanupError == nil { + cleanupError = error + } + } + } + if let cleanupError { + throw cleanupError + } + } + + private func cssRequiresPageCapability( + for route: RoutingTargetID + ) -> Bool { + switch route.storage { + case .currentPage: + return true + case let .target(rawValue): + return targetRegistry.target( + for: ProtocolTarget.ID(rawValue) + )?.kind == .page + } + } + + package nonisolated func send(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { + try Task.checkCancellation() + + switch command.authority { + case .direct: + return try await sendDirect(command) + case let .modelFeed(authorization): + let operation = try await beginModelCommand(command, authorization: authorization) + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await operation.task.value + } onCancel: { + operation.control.cancelByCaller() + } + } + } + + private func sendDirect(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { + guard isOpen else { + throw terminalTransportError + } + // Admission owns both transport-local and wire-backed commands. A + // model feed must never be bypassed by DOM.enable's local result. + switch command.routing { + case .root: + try claimDirectConsumer() + return try await sendRoot( + command, + admission: ConnectionDirectCommandAdmission( + bindingGeneration: nil, + documentEpoch: nil + ) + ) + case let .target(targetID): + return try await sendDirectTarget(command, targetID: targetID) + case let .octopus(pageTarget): + let resolvedTarget = try pageTarget ?? currentMainPageTarget() + return try await sendDirectTarget(command, targetID: resolvedTarget) + } + } + + private func sendDirectTarget( + _ command: ProtocolCommand, + targetID: ProtocolTarget.ID + ) async throws -> ProtocolCommand.Result { + guard let target = targetRegistry.target(for: targetID) else { + throw TransportSession.Error.missingTarget(targetID) + } + try claimDirectConsumer() + let isCurrentPageTarget = targetRegistry.isCurrentPageModelTarget(target) + let admission = ConnectionDirectCommandAdmission( + bindingGeneration: isCurrentPageTarget ? currentPageGeneration : nil, + documentEpoch: isCurrentPageTarget && isDocumentSensitive( + command.domain, + method: command.method + ) + ? modelDocumentEpoch(for: targetID) + : nil + ) + if let result = transportLocalResult(for: command, targetID: targetID) { + return result + } + return try await sendTarget( + command, + targetID: targetID, + owner: .direct(admission) + ) + } + + private func beginModelCommand( + _ command: ProtocolCommand, + authorization: ConnectionModelCommandAuthorization + ) throws -> ConnectionModelCommandOperation { + try Task.checkCancellation() + try validateModelCommandAuthority( + authorization, + domain: command.domain, + method: command.method + ) + precondition( + nextModelCommandOperationID < UInt64.max, + "A model command operation identifier exhausted UInt64." + ) + nextModelCommandOperationID += 1 + let operationID = nextModelCommandOperationID + let control = ConnectionModelCommandControl() + let task = Task { [weak self, command, authorization, control] () throws -> ProtocolCommand.Result in + do { + while true { + try Task.checkCancellation() + guard let step = try await self?.prepareModelCommandStep( + command, + authorization: authorization, + operationID: operationID + ) else { + throw WebInspectorProxyError.closed + } + switch step { + case let .waiting(signal): + try await signal.value() + case let .ready(operation): + let value = try await operation.result() + let fallback = control.resolve(.success(value)) + let resolved = await self?.finishModelCommand( + operationID, + fallback: fallback + ) ?? fallback + return try resolved.get() + } + } + } catch { + let fallback = control.resolve(.failure(error)) + let resolved = await self?.finishModelCommand( + operationID, + fallback: fallback + ) ?? fallback + return try resolved.get() + } + } + precondition( + modelCommandTasks[operationID] == nil, + "A model command operation identifier already has an owner." + ) + modelCommandTasks[operationID] = ConnectionModelCommandTask( + control: control, + authorization: authorization, + domain: command.domain, + method: command.method, + routing: command.routing, + pendingReplyOwnership: nil, + readinessSignal: nil + ) + control.install(task) + resumeModelCommandOwnerCountWaitersIfNeeded() + return ConnectionModelCommandOperation(task: task, control: control) + } + + private func prepareModelCommandStep( + _ command: ProtocolCommand, + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) throws -> ConnectionModelCommandStep { + guard var task = modelCommandTasks[operationID] else { + throw ConnectionModelCommandError.notActive + } + if let failure = task.control.failure { + throw failure.error + } + switch try modelCommandReadiness( + authorization: authorization, + domain: command.domain, + method: command.method, + routing: command.routing + ) { + case let .ready(targetID): + task.readinessSignal = nil + modelCommandTasks[operationID] = task + // There is intentionally no suspension between this final + // readiness check and pending-reply insertion. + return .ready(try makeModelCommandOperation( + command, + targetID: targetID, + authorization: authorization, + operationID: operationID + )) + case .waiting: + if let signal = task.readinessSignal { + return .waiting(signal) + } + let signal = ReplyPromise() + task.readinessSignal = signal + modelCommandTasks[operationID] = task + resumeModelCommandOwnerCountWaitersIfNeeded() + return .waiting(signal) + } + } + + private func validateModelCommandAuthority( + _ authorization: ConnectionModelCommandAuthorization, + domain: ProtocolDomain, + method: String + ) throws { + guard isOpen else { + throw terminalTransportError + } + guard let registration = modelFeed, + registration.id == authorization.feedID else { + throw ConnectionModelCommandError.notActive + } + guard case .active = registration.lifecycle else { + throw ConnectionModelCommandError.notActive + } + guard authorization.generation == registration.resetGeneration else { + throw WebInspectorProxyError.staleIdentifier + } + guard let proxyDomain = proxyDomain(for: domain) else { + throw ConnectionModelCommandError.notActive + } + if isConnectionOwnedCommand(method) { + throw ConnectionModelCommandError.internalCommand( + domain: proxyDomain, + method: method + ) + } + if let requiredDomain = requiredModelDomain(for: domain), + !registration.configuredDomains.contains(requiredDomain) { + throw ConnectionModelCommandError.domainNotConfigured(proxyDomain) + } + if isDocumentSensitive(domain, method: method), authorization.document == nil { + throw ConnectionModelCommandError.documentAuthorizationRequired(proxyDomain) + } + } + + private func modelCommandReadiness( + authorization: ConnectionModelCommandAuthorization, + domain: ProtocolDomain, + method: String, + routing: ProtocolCommand.Routing + ) throws -> ConnectionModelCommandReadiness { + try validateModelCommandAuthority( + authorization, + domain: domain, + method: method + ) + if currentPageGeneration.rawValue > authorization.generation.rawValue { + throw WebInspectorProxyError.staleIdentifier + } + guard currentPageGeneration == authorization.generation else { + return .waiting + } + + let targetID: ProtocolTarget.ID? + switch routing { + case .root: + targetID = nil + case let .target(candidate): + guard let record = targetRegistry.target(for: candidate), + targetRegistry.isCurrentPageModelTarget(record) else { + throw WebInspectorProxyError.staleIdentifier + } + targetID = candidate + case let .octopus(explicitTarget): + guard let candidate = explicitTarget ?? targetRegistry.currentMainPageTargetID else { + return .waiting + } + guard let record = targetRegistry.target(for: candidate), + targetRegistry.isCurrentPageModelTarget(record) else { + throw WebInspectorProxyError.staleIdentifier + } + targetID = candidate + } + + if isDocumentSensitive(domain, method: method) { + guard let targetID, + let document = authorization.document, + ProtocolTarget.ID(document.targetID.rawValue) == targetID, + modelDocumentEpoch(for: targetID) == document.epoch else { + throw WebInspectorProxyError.staleIdentifier + } + } + + guard let registration = modelFeed, + let synchronization = registration.synchronization, + synchronization.generation == authorization.generation, + synchronization.didPublish else { + return .waiting + } + + if isDocumentSensitive(domain, method: method) { + guard let targetID, + let document = authorization.document, + let bootstrap = registration.domBootstrap, + bootstrap.generation == authorization.generation, + bootstrap.targetsByID[targetID]?.completedEpoch == document.epoch else { + return .waiting + } + } + return .ready(targetID: targetID) + } + + private func reevaluateModelCommandReadinessWaiters() { + for operationID in modelCommandTasks.keys.sorted() { + guard var task = modelCommandTasks[operationID], + let signal = task.readinessSignal else { + continue + } + do { + guard case .ready = try modelCommandReadiness( + authorization: task.authorization, + domain: task.domain, + method: task.method, + routing: task.routing + ) else { + continue + } + task.readinessSignal = nil + modelCommandTasks[operationID] = task + signal.fulfill(.success(())) + } catch { + task.readinessSignal = nil + modelCommandTasks[operationID] = task + signal.fulfill(.failure(error)) + } + } + resumeModelCommandOwnerCountWaitersIfNeeded() + } + + private func makeModelCommandOperation( + _ command: ProtocolCommand, + targetID: ProtocolTarget.ID?, + authorization: ConnectionModelCommandAuthorization, + operationID: UInt64 + ) throws -> ConnectionOwnedCommandOperation { + let commandID = allocateCommandID() + let promise = ReplyPromise() + let message = try TransportMessageParser.makeCommandString( + id: commandID, + method: command.method, + parametersData: command.parametersData + ) + let pending = TransportSession.PendingReply.modelCommand( + domain: command.domain, + method: command.method, + targetID: targetID, + promise: promise, + authorization: authorization, + operationID: operationID + ) + + let pendingKey: TransportSession.PendingKey + let wireMessage: String + if let targetID { + guard targetRegistry.containsTarget(targetID) else { + throw WebInspectorProxyError.staleIdentifier + } + let wrapperID = allocateCommandID() + let replyKey = TransportSession.ReplyKey( + targetID: targetID, + commandID: commandID + ) + pendingKey = .target(replyKey) + wireMessage = try TransportMessageParser.makeTargetWrapperCommandString( + id: wrapperID, + targetIdentifier: targetID.rawValue, + message: message + ) + replyStore.insertTargetReply( + pending, + key: replyKey, + rootWrapperID: wrapperID + ) + } else { + pendingKey = .root(commandID) + wireMessage = message + replyStore.insertRootReply(pending, commandID: commandID) + } + + let ownership = ConnectionPendingReplyOwnership( + key: pendingKey, + purpose: pending.purpose + ) + guard var task = modelCommandTasks[operationID] else { + preconditionFailure("A model command pending reply has no task owner.") + } + task.pendingReplyOwnership = ownership + modelCommandTasks[operationID] = task + + let timeoutAction: (@Sendable () async -> Void)? + if let responseTimeout { + let timeoutSleep = self.timeoutSleep + let responseTimeoutDidFire = self.responseTimeoutDidFire + timeoutAction = { [weak self] in + do { + try await timeoutSleep(responseTimeout) + } catch { + return + } + await self?.failPendingReplyFromTimeout( + pendingKey, + error: TransportSession.Error.replyTimeout( + method: command.method, + targetID: targetID + ) + ) + await responseTimeoutDidFire() + } + } else { + timeoutAction = nil + } + return ConnectionOwnedCommandOperation( + backend: backend, + message: wireMessage, + promise: promise, + pendingReplyOwnership: ownership, + timeoutAction: timeoutAction + ) + } + + private func finishModelCommand( + _ operationID: UInt64, + fallback: Result + ) -> Result { + guard let task = modelCommandTasks.removeValue(forKey: operationID) else { + return fallback + } + if let ownership = task.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition( + pending.purpose == ownership.purpose, + "A model command task attempted to remove a reply owned by another purpose." + ) + } + task.readinessSignal?.fulfill(.failure(CancellationError())) + resumeModelCommandOwnerCountWaitersIfNeeded() + return task.control.resolve(fallback) + } + + private func requiredModelDomain(for domain: ProtocolDomain) -> ModelDomain? { + switch domain { + case .dom, .inspector: + .dom + case .css: + .css + case .network: + .network + case .console: + .console + case .runtime: + .runtime + case .page: + nil + case .target, .storage, .other: + nil + } + } + + private func proxyDomain(for domain: ProtocolDomain) -> WebInspectorProxyDomain? { + switch domain { + case .dom: + .dom + case .css: + .css + case .network: + .network + case .console: + .console + case .runtime: + .runtime + case .page: + .page + case .inspector: + .inspector + case .target, .storage, .other: + nil + } + } + + private func isDocumentSensitive( + _ domain: ProtocolDomain, + method: String + ) -> Bool { + if domain == .dom, method == "DOM.hideHighlight" { + return false + } + return domain == .dom || domain == .css || domain == .inspector + } + + private func isConnectionOwnedCommand(_ method: String) -> Bool { + method.hasSuffix(".enable") + || method.hasSuffix(".disable") + || method == "DOM.getDocument" + || method == "Inspector.initialized" + } + + private func invalidateModelCommands( + where shouldInvalidate: (ConnectionModelCommandTask) -> Bool, + failureOverride: ConnectionModelCommandFailureOverride, + pendingFailureReason: ConnectionPendingReplyFailureReason + ) -> ConnectionCommandInvalidationEffects { + var effects = ConnectionCommandInvalidationEffects() + for operationID in modelCommandTasks.keys.sorted() { + guard var commandTask = modelCommandTasks[operationID], + shouldInvalidate(commandTask) else { + continue + } + commandTask.control.failFromOwner(failureOverride) + let readinessSignal = commandTask.readinessSignal + commandTask.readinessSignal = nil + modelCommandTasks[operationID] = commandTask + readinessSignal?.fulfill(.failure(failureOverride.error)) + + if let ownership = commandTask.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition( + pending.purpose == ownership.purpose, + "A model command invalidation removed another operation's reply." + ) + effects.pendingFailures.append( + ConnectionPendingReplyFailure( + pending: pending, + reason: pendingFailureReason + ) + ) + } + if let task = commandTask.control.task { + effects.modelCommandTasksToAwait.append(task) + } + } + resumeModelCommandOwnerCountWaitersIfNeeded() + return effects + } + + private func completeCommandInvalidationEffects( + _ effects: ConnectionCommandInvalidationEffects + ) async { + for failure in effects.pendingFailures { + failure.pending.promise.fulfill(.failure(failure.reason.error)) + } + for task in effects.modelCommandTasksToAwait { + _ = await task.result + } + } + + @discardableResult + package func receiveRootMessage(_ message: String) async -> UInt64 { + guard isOpen else { + return eventSequences.current.sequence + } + inboundMessageQueue.append(message) + await drainInboundMessages() + return eventSequences.current.sequence + } + + package func detach() async { + await close() + } + + package func close() async { + await terminate(.explicitClose) + } + + @discardableResult + package nonisolated func failFromNativeCallback(_ message: String) -> Task? { + let cause = TerminalCause.fatal(message) + let claim = terminalClaim.claim(cause) + guard claim.claimedProposedCause else { + return nil + } + return Task { [weak self] in + await self?.beginClaimedTerminationHandoff(cause) + } + } + + package func waitUntilClosed() async throws { + switch state { + case .open, .closing: + break + case .closed: + return try terminalResult(for: claimedTerminalCause).get() + } + + nextCloseWaiterID &+= 1 + let waiterID = nextCloseWaiterID + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerCloseWaiter(id: waiterID, continuation: continuation) + } + } onCancel: { + Task { [weak self] in + await self?.cancelCloseWaiter(waiterID) + } + } + } + + package func waitForCloseWaiterForTesting() async { + if case .closed = state { + preconditionFailure("Cannot wait for a close waiter after ConnectionCore closed.") + } + guard closeWaiters.isEmpty else { + return + } + await withCheckedContinuation { continuation in + closeWaiterRegistrationWaiters.append(continuation) + } + } + + package func waitForEventScopeCountForTesting(_ expectedCount: Int) async { + precondition(expectedCount >= 0) + guard eventScopes.entries.count != expectedCount else { + return + } + await withCheckedContinuation { continuation in + eventScopeRegistrationWaiters.append((expectedCount, continuation)) + } + } + + package func activeEventScopeSubscriberCountForTesting() -> Int { + eventScopes.entries.values.count { $0.sink != nil } + } + + package func waitForModelCommandOwnerCountForTesting(_ expectedCount: Int) async { + precondition(expectedCount >= 0) + guard modelCommandTasks.count != expectedCount else { + return + } + await withCheckedContinuation { continuation in + modelCommandOwnerCountWaiters.append((expectedCount, continuation)) + } + } + + package func startModelCommandForTesting( + _ command: ProtocolCommand, + authorization: ConnectionModelCommandAuthorization + ) throws -> Task { + try beginModelCommand(command, authorization: authorization).task + } + + package func modelCommandOwnerCountForTesting() -> Int { + modelCommandTasks.count + } + + package func modelCommandReadinessWaiterCountForTesting() -> Int { + modelCommandTasks.values.count { $0.readinessSignal != nil } + } + + package func waitForModelCommandReadinessWaiterCountForTesting( + _ expectedCount: Int + ) async { + precondition(expectedCount >= 0) + guard modelCommandReadinessWaiterCountForTesting() != expectedCount else { + return + } + await withCheckedContinuation { continuation in + modelCommandReadinessCountWaiters.append((expectedCount, continuation)) + } + } + + private func resumeModelCommandOwnerCountWaitersIfNeeded() { + let count = modelCommandTasks.count + var pending: [( + expectedCount: Int, + continuation: CheckedContinuation + )] = [] + for waiter in modelCommandOwnerCountWaiters { + if count == waiter.expectedCount { + waiter.continuation.resume() + } else { + pending.append(waiter) + } + } + modelCommandOwnerCountWaiters = pending + + let readinessCount = modelCommandReadinessWaiterCountForTesting() + var pendingReadiness: [( + expectedCount: Int, + continuation: CheckedContinuation + )] = [] + for waiter in modelCommandReadinessCountWaiters { + if readinessCount == waiter.expectedCount { + waiter.continuation.resume() + } else { + pendingReadiness.append(waiter) + } + } + modelCommandReadinessCountWaiters = pendingReadiness + } + + package func replaceEventScopeActivationCancellationActionForTesting( + _ action: @escaping @Sendable () async -> Void + ) { + eventScopeActivationCancellationAction = action + } + + package func replaceModelTargetMutationActionForTesting( + _ action: (@Sendable () -> Void)? + ) { + modelTargetMutationActionForTesting = action + } + + package func replaceCloseActionForTesting(_ action: @escaping CloseAction) { + precondition(isOpen, "Close action must be installed before closing begins.") + closeAction = action + } + + package func requireOpen() throws { + guard isOpen else { + throw terminalTransportError + } + } + + package var terminalCause: TerminalCause? { + terminalClaim.current + } + + package func waitForCurrentMainPageTarget(timeout: Duration? = nil) async throws -> TransportSession.MainPageTarget { + guard isOpen else { + throw terminalTransportError + } + if let currentMainPageTargetID = targetRegistry.currentMainPageTargetID { + return TransportSession.MainPageTarget( + targetID: currentMainPageTargetID, + receivedSequence: eventSequences.current.sequence + ) + } + + let waiter = mainPageTargetWaiterStore.insert() + + let timeoutTask: Task? = timeout.map { timeout in + let timeoutSleep = self.timeoutSleep + return Task { + do { + try await timeoutSleep(timeout) + } catch { + return + } + self.failMainPageTargetWaiter(waiter.id, error: TransportSession.Error.missingMainPageTarget) + } + } + defer { + timeoutTask?.cancel() + } + + do { + return try await waiter.promise.value() + } catch { + mainPageTargetWaiterStore.remove(id: waiter.id) + throw error + } + } + + package func snapshot() -> TransportSession.Snapshot { + TransportSession.Snapshot( + currentMainPageTargetID: targetRegistry.currentMainPageTargetID, + targetsByID: targetRegistry.targetsByID, + frameTargetIDsByFrameID: targetRegistry.frameTargetIDsByFrameID, + executionContextsByKey: runtimeContextRegistry.contextsByKey, + pendingRootReplyIDs: replyStore.pendingRootReplyIDs, + pendingTargetReplyKeys: replyStore.pendingTargetReplyKeys + ) + } + + func pendingReplyPurposes() -> [ + TransportSession.PendingKey: TransportSession.PendingReply.Purpose + ] { + replyStore.pendingReplyPurposes + } + + func capabilityLeaseOwnersForTesting() -> [ + ConnectionCapabilityKey: Set + ] { + capabilities.states.mapValues(\.leaseOwners) + } + + func desiredCapabilityLeaseOwnersForTesting() -> [ + ConnectionCapabilityKey: Set + ] { + capabilities.states.mapValues(\.desiredLeaseOwners) + } + + package func targetID(forExecutionContext key: RuntimeContext.Key) -> ProtocolTarget.ID? { + runtimeContextRegistry.targetID(for: key) + } + + package func targetID(forFrameID frameID: ProtocolFrame.ID) -> ProtocolTarget.ID? { + targetRegistry.targetID(forFrameID: frameID) + } + + package func currentMainPageRecord() -> ProtocolTarget.Record? { + guard isOpen, + let targetID = targetRegistry.currentMainPageTargetID else { + return nil + } + return targetRegistry.target(for: targetID) + } + + private func requireAvailableTarget(for route: RoutingTargetID) throws { + switch route.storage { + case let .target(rawValue): + guard targetRegistry.containsTarget(ProtocolTarget.ID(rawValue)) else { + throw WebInspectorProxyError.pageUnavailable + } + case .currentPage: + guard targetRegistry.currentMainPageTargetID != nil else { + throw WebInspectorProxyError.pageUnavailable + } + } + } + + private func resumeEventScopeRegistrationWaitersIfNeeded() { + let count = eventScopes.entries.count + var pending: [( + expectedCount: Int, + continuation: CheckedContinuation + )] = [] + for waiter in eventScopeRegistrationWaiters { + if count == waiter.expectedCount { + waiter.continuation.resume() + } else { + pending.append(waiter) + } + } + eventScopeRegistrationWaiters = pending + } + + private func generation(for route: RoutingTargetID) -> WebInspectorPage.Generation { + switch route.storage { + case .currentPage: + currentPageGeneration + case .target: + WebInspectorPage.Generation(rawValue: 0) + } + } + + private func claimDirectConsumer() throws { + guard modelFeed == nil else { + throw WebInspectorProxyError.connectionInUse + } + replayWasTaintedByDirectConsumer = true + } + + private func enqueueModelFeedRecord( + _ record: ConnectionModelFeedRecord + ) -> Bool { + guard let registration = modelFeed else { + return true + } + switch registration.mailbox.enqueue(record) { + case .enqueued: + return true + case .terminated: + handoffTermination( + .modelFeedFailure(ConnectionModelFeedError.consumerTerminated) + ) + return false + } + } + + @discardableResult + private func publishModelTargetSnapshot() -> Bool { + guard var registration = modelFeed else { + return true + } + guard let snapshot = targetRegistry.modelTargetSnapshot() else { + preconditionFailure("A current page binding has no model target snapshot.") + } + let through = eventSequences.current.sequence + guard enqueueModelFeedRecord( + .targetSnapshot( + generation: currentPageGeneration, + through: through, + snapshot: snapshot + ) + ) else { + return false + } + registration.targetSnapshotThrough = through + registration.synchronization = ConnectionModelFeedSynchronizationState( + generation: currentPageGeneration + ) + if registration.configuredDomains.contains(.dom) { + registration.domBootstrap = makeDOMBootstrapState( + snapshot: snapshot, + generation: currentPageGeneration + ) + } else { + registration.domBootstrap = nil + } + modelFeed = registration + + guard publishModelSynchronizationIfReady() else { + return false + } + startNextDOMBootstrapIfNeeded() + return isOpen + } + + @discardableResult + private func completeModelDomain( + _ domain: ModelDomain, + generation: WebInspectorPage.Generation + ) -> Bool { + guard var registration = modelFeed, + var synchronization = registration.synchronization, + synchronization.generation == generation else { + return true + } + guard registration.configuredDomains.contains(domain) else { + preconditionFailure("A model feed completed a domain that it did not configure.") + } + precondition( + synchronization.completedDomains.insert(domain).inserted, + "A model feed domain completed more than once in one binding generation." + ) + registration.synchronization = synchronization + modelFeed = registration + return publishModelSynchronizationIfReady() + } + + @discardableResult + private func publishModelSynchronizationIfReady( + allowWhileAcquiring: Bool = false + ) -> Bool { + guard var registration = modelFeed, + var synchronization = registration.synchronization else { + return true + } + switch registration.lifecycle { + case .active: + break + case .acquiring where allowWhileAcquiring: + break + case .acquiring, .rollingBack, .closing: + return true + } + guard synchronization.completedDomains == registration.configuredDomains else { + return true + } + precondition( + !synchronization.didPublish, + "A model feed published synchronization more than once in one binding generation." + ) + guard enqueueModelFeedRecord( + .synchronizationComplete( + generation: synchronization.generation, + through: eventSequences.current.sequence + ) + ) else { + return false + } + synchronization.didPublish = true + registration.synchronization = synchronization + modelFeed = registration + reevaluateModelCommandReadinessWaiters() + return true + } + + private func makeDOMBootstrapState( + snapshot: ModelTargetSnapshot, + generation: WebInspectorPage.Generation + ) -> ConnectionDOMBootstrapState { + var targetsByID: [ProtocolTarget.ID: ConnectionDOMBootstrapState.TargetState] = [:] + let orderedTargetIDs = snapshot.targets.map { target in + let targetID = ProtocolTarget.ID(target.id.rawValue) + precondition( + !targetsByID.keys.contains(targetID), + "A model target snapshot contains duplicate physical targets." + ) + targetsByID[targetID] = ConnectionDOMBootstrapState.TargetState( + target: target, + completedEpoch: nil + ) + _ = modelDocumentEpoch(for: targetID) + return targetID + } + return ConnectionDOMBootstrapState( + generation: generation, + orderedTargetIDs: orderedTargetIDs, + targetsByID: targetsByID, + activeOperation: nil, + needsCompletionMarker: true + ) + } + + private func modelDocumentEpoch( + for targetID: ProtocolTarget.ID + ) -> ModelDocumentEpoch { + if let epoch = modelDocumentEpochs[targetID] { + return epoch + } + let epoch = ModelDocumentEpoch(rawValue: 0) + modelDocumentEpochs[targetID] = epoch + return epoch + } + + private func advanceModelDocumentEpoch( + for targetID: ProtocolTarget.ID + ) -> ModelDocumentEpoch { + let current = modelDocumentEpoch(for: targetID) + precondition( + current.rawValue < UInt64.max, + "A DOM document epoch exhausted UInt64." + ) + let next = ModelDocumentEpoch(rawValue: current.rawValue + 1) + modelDocumentEpochs[targetID] = next + return next + } + + private func startNextDOMBootstrapIfNeeded() { + guard isOpen, + var registration = modelFeed, + var bootstrap = registration.domBootstrap, + bootstrap.generation == currentPageGeneration, + bootstrap.activeOperation == nil else { + return + } + switch registration.lifecycle { + case .acquiring, .active: + break + case .rollingBack, .closing: + return + } + + let nextTargetID = bootstrap.orderedTargetIDs.first { targetID in + guard let target = bootstrap.targetsByID[targetID] else { + preconditionFailure("A DOM bootstrap order entry lost its target state.") + } + return target.completedEpoch != modelDocumentEpoch(for: targetID) + } + guard let targetID = nextTargetID else { + guard bootstrap.needsCompletionMarker else { + return + } + guard enqueueModelFeedRecord( + .bootstrapComplete( + generation: bootstrap.generation, + domain: .dom, + through: eventSequences.current.sequence + ) + ) else { + return + } + bootstrap.needsCompletionMarker = false + let generation = bootstrap.generation + let domWasAlreadyComplete = registration.synchronization? + .completedDomains.contains(.dom) == true + registration.domBootstrap = bootstrap + modelFeed = registration + if !domWasAlreadyComplete { + _ = completeModelDomain(.dom, generation: generation) + } + reevaluateModelCommandReadinessWaiters() + return + } + + precondition( + nextModelBootstrapOperationID < UInt64.max, + "A DOM bootstrap operation identifier exhausted UInt64." + ) + nextModelBootstrapOperationID += 1 + let operationID = nextModelBootstrapOperationID + let documentEpoch = modelDocumentEpoch(for: targetID) + bootstrap.activeOperation = ConnectionDOMBootstrapState.ActiveOperation( + id: operationID, + targetID: targetID, + documentEpoch: documentEpoch, + replyDisposition: nil + ) + registration.domBootstrap = bootstrap + modelFeed = registration + + let feedID = registration.id + let generation = bootstrap.generation + let operation: ConnectionOwnedCommandOperation + do { + operation = try makeDOMBootstrapCommandOperation( + feedID: feedID, + generation: generation, + targetID: targetID, + documentEpoch: documentEpoch, + operationID: operationID + ) + } catch { + handoffTermination(.fatal( + "Failed to construct DOM.getDocument for target \(targetID.rawValue): \(error)" + )) + return + } + + let task = Task { [weak self, operation] in + let result: Result + do { + try await operation.value() + result = .success(()) + } catch { + result = .failure(error) + } + await self?.completeDOMBootstrapOperation( + id: operationID, + feedID: feedID, + generation: generation, + targetID: targetID, + documentEpoch: documentEpoch, + result: result + ) + } + let ownership = operation.pendingReplyOwnership + precondition( + modelBootstrapTasks[operationID] == nil, + "A DOM bootstrap operation identifier already has an owner." + ) + modelBootstrapTasks[operationID] = ConnectionModelBootstrapTask( + task: task, + pendingReplyOwnership: ownership, + feedID: feedID, + generation: generation + ) + } + + private func makeDOMBootstrapCommandOperation( + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + targetID: ProtocolTarget.ID, + documentEpoch: ModelDocumentEpoch, + operationID: UInt64 + ) throws -> ConnectionOwnedCommandOperation { + guard targetRegistry.containsTarget(targetID) else { + throw TransportSession.Error.missingTarget(targetID) + } + let innerCommandID = allocateCommandID() + let outerCommandID = allocateCommandID() + let replyKey = TransportSession.ReplyKey( + targetID: targetID, + commandID: innerCommandID + ) + let pendingKey = TransportSession.PendingKey.target(replyKey) + let message = try TransportMessageParser.makeCommandString( + id: innerCommandID, + method: "DOM.getDocument", + parametersData: Data("{}".utf8) + ) + let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( + id: outerCommandID, + targetIdentifier: targetID.rawValue, + message: message + ) + let promise = ReplyPromise() + let pendingReply = TransportSession.PendingReply.modelBootstrap( + targetID: targetID, + promise: promise, + feedID: feedID, + generation: generation, + documentEpoch: documentEpoch, + operationID: operationID + ) + replyStore.insertTargetReply( + pendingReply, + key: replyKey, + rootWrapperID: outerCommandID + ) + let ownership = ConnectionPendingReplyOwnership( + key: pendingKey, + purpose: pendingReply.purpose + ) + + let timeoutAction: (@Sendable () async -> Void)? + if let responseTimeout { + let timeoutSleep = self.timeoutSleep + let responseTimeoutDidFire = self.responseTimeoutDidFire + timeoutAction = { [weak self] in + do { + try await timeoutSleep(responseTimeout) + } catch { + return + } + await self?.failPendingReplyFromTimeout( + pendingKey, + error: TransportSession.Error.replyTimeout( + method: "DOM.getDocument", + targetID: targetID + ) + ) + await responseTimeoutDidFire() + } + } else { + timeoutAction = nil + } + + return ConnectionOwnedCommandOperation( + backend: backend, + message: wrapperMessage, + promise: promise, + pendingReplyOwnership: ownership, + timeoutAction: timeoutAction + ) + } + + private func completeDOMBootstrapOperation( + id: UInt64, + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + targetID: ProtocolTarget.ID, + documentEpoch: ModelDocumentEpoch, + result: Result + ) { + if let ownership = modelBootstrapTasks.removeValue(forKey: id)?.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition( + pending.purpose == ownership.purpose, + "A DOM bootstrap task attempted to remove a reply owned by another purpose." + ) + } + guard var registration = modelFeed, + registration.id == feedID, + var bootstrap = registration.domBootstrap, + bootstrap.generation == generation, + let active = bootstrap.activeOperation, + active.id == id, + active.targetID == targetID, + active.documentEpoch == documentEpoch else { + return + } + bootstrap.activeOperation = nil + registration.domBootstrap = bootstrap + modelFeed = registration + + if active.replyDisposition == .terminal { + // Reply publication is the operation's terminal authority. The + // connection teardown races its cancellation against promise + // fulfillment, so consume this disposition before interpreting + // the task's generic success or cancellation result. + return + } + guard isOpen else { + return + } + + switch result { + case .success: + guard let replyDisposition = active.replyDisposition else { + preconditionFailure( + "A successful DOM bootstrap operation has no reply-side disposition." + ) + } + switch replyDisposition { + case .published, .stale: + startNextDOMBootstrapIfNeeded() + case .terminal: + preconditionFailure( + "A terminal DOM bootstrap reply disposition escaped its operation boundary." + ) + } + case let .failure(error): + let targetIsStillRequired = bootstrap.targetsByID[targetID] != nil + && modelDocumentEpoch(for: targetID) == documentEpoch + && targetRegistry.target(for: targetID).map( + targetRegistry.isCurrentPageModelTarget + ) == true + guard targetIsStillRequired else { + startNextDOMBootstrapIfNeeded() + return + } + if let transportError = error as? TransportSession.Error, + case let .remoteError(_, _, message) = transportError { + handoffTermination( + .modelFeedFailure( + .bootstrapFailed(domain: .dom, message: message) + ) + ) + return + } + handoffTermination(.fatal( + "DOM.getDocument failed for required target \(targetID.rawValue): \(error)" + )) + } + } + + private func reconcileDOMBootstrapTargets() { + guard var registration = modelFeed, + var bootstrap = registration.domBootstrap, + bootstrap.generation == currentPageGeneration, + let snapshot = targetRegistry.modelTargetSnapshot() else { + return + } + + var targetsByID: [ProtocolTarget.ID: ConnectionDOMBootstrapState.TargetState] = [:] + var addedTarget = false + let orderedTargetIDs = snapshot.targets.map { target in + let targetID = ProtocolTarget.ID(target.id.rawValue) + precondition( + !targetsByID.keys.contains(targetID), + "A reconciled model target snapshot contains duplicate physical targets." + ) + let currentEpoch = modelDocumentEpoch(for: targetID) + if var existing = bootstrap.targetsByID[targetID] { + existing = ConnectionDOMBootstrapState.TargetState( + target: target, + completedEpoch: existing.completedEpoch + ) + targetsByID[targetID] = existing + if existing.completedEpoch != currentEpoch { + bootstrap.needsCompletionMarker = true + } + } else { + targetsByID[targetID] = ConnectionDOMBootstrapState.TargetState( + target: target, + completedEpoch: nil + ) + addedTarget = true + } + return targetID + } + if addedTarget { + bootstrap.needsCompletionMarker = true + } + bootstrap.orderedTargetIDs = orderedTargetIDs + bootstrap.targetsByID = targetsByID + registration.domBootstrap = bootstrap + modelFeed = registration + startNextDOMBootstrapIfNeeded() + } + + private func prepareDOMDocumentUpdateForModelFeed( + _ event: ProtocolEvent + ) -> ConnectionCommandInvalidationEffects { + guard event.method == "DOM.documentUpdated" else { + return ConnectionCommandInvalidationEffects() + } + let targetID = event.targetID + ?? event.sourceTargetID + ?? targetRegistry.currentMainPageTargetID + guard let targetID, + let targetRecord = targetRegistry.target(for: targetID), + targetRegistry.isCurrentPageModelTarget(targetRecord) else { + return ConnectionCommandInvalidationEffects() + } + let oldEpoch = modelDocumentEpoch(for: targetID) + let documentEpoch = advanceModelDocumentEpoch(for: targetID) + + if var registration = modelFeed, + var bootstrap = registration.domBootstrap, + bootstrap.generation == currentPageGeneration, + bootstrap.targetsByID[targetID] != nil { + bootstrap.needsCompletionMarker = true + registration.domBootstrap = bootstrap + modelFeed = registration + } + + var effects = invalidateModelCommands( + where: { task in + guard task.authorization.generation == currentPageGeneration, + isDocumentSensitive(task.domain, method: task.method), + let document = task.authorization.document else { + return false + } + return ProtocolTarget.ID(document.targetID.rawValue) == targetID + && document.epoch == oldEpoch + }, + failureOverride: .staleIdentifier, + pendingFailureReason: .staleIdentifier + ) + let bindingReplies = replyStore.removePendingReplies { pending in + guard pending.targetID == targetID, + isDocumentSensitive(pending.domain, method: pending.method) else { + return false + } + let bindingGeneration: WebInspectorPage.Generation? + let documentEpoch: ModelDocumentEpoch? + switch pending.purpose { + case let .direct(generation, epoch): + bindingGeneration = generation + documentEpoch = epoch + case let .elementPickerMode(_, generation, epoch, _): + bindingGeneration = generation + documentEpoch = epoch + case .modelCommand, .capability, .capabilityAuxiliary, .modelBootstrap: + return false + } + return bindingGeneration == currentPageGeneration + && documentEpoch == oldEpoch + } + effects.pendingFailures.append(contentsOf: bindingReplies.map { + ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) + }) + + if let registration = modelFeed, + registration.configuredDomains.contains(.dom), + registration.targetSnapshotThrough != nil { + guard let target = ModelTarget(record: targetRecord) else { + preconditionFailure( + "A current-page DOM target cannot be represented in the model feed." + ) + } + _ = enqueueModelFeedRecord( + .domDocumentInvalidated( + generation: currentPageGeneration, + sequence: event.sequence, + target: target, + documentEpoch: documentEpoch + ) + ) + } + return effects + } + + private func publishModelTargetLifecycleEvent( + _ event: ModelTargetLifecycleEvent, + sequence: UInt64 + ) { + guard let registration = modelFeed, + targetRegistry.currentMainPageTargetID != nil, + let targetSnapshotThrough = registration.targetSnapshotThrough, + sequence > targetSnapshotThrough else { + return + } + _ = enqueueModelFeedRecord( + .event( + generation: currentPageGeneration, + sequence: sequence, + payload: .target(event) + ) + ) + } + + private func beginCapabilityLease( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation + ) -> ReplyPromise? { + var capability = capabilities.states[key] + ?? ConnectionCapabilityRegistry.State(physical: .inactive(generation: generation)) + precondition( + capability.leaseOwners.insert(leaseOwner).inserted, + "Duplicate capability lease owner." + ) + + let activation: ReplyPromise? + if case let .enabled(activeGeneration) = capability.physical, + activeGeneration == generation { + activation = nil + precondition( + capability.activatedLeaseOwners.insert(leaseOwner).inserted, + "A newly registered capability lease was already activated." + ) + } else { + let promise = ReplyPromise() + capability.activationWaiters[leaseOwner] = promise + activation = promise + } + capabilities.states[key] = capability + return activation + } + + private func activateCapabilityLease( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey, + activation: ReplyPromise? + ) async throws { + do { + await reconcileCapability(for: key) + try Task.checkCancellation() + try await activation?.value() + } catch { + if error is CancellationError { + await cancelCapabilityActivation(leaseOwner, for: key) + } + throw error + } + } + + private func cancelCapabilityActivation( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async { + guard var capability = capabilities.states[key], + let waiter = capability.activationWaiters.removeValue(forKey: leaseOwner) else { + return + } + capability.failedLeaseOwners.insert(leaseOwner) + capabilities.states[key] = capability + if case .eventScope = leaseOwner { + let cancellationAction = eventScopeActivationCancellationAction + eventScopeActivationCancellationAction = nil + await cancellationAction?() + } + waiter.fulfill(.failure(CancellationError())) + } + + private func releaseCapabilityLease( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async throws { + guard var capability = capabilities.states[key] else { + return + } + guard capability.leaseOwners.remove(leaseOwner) != nil else { + return + } + capability.failedLeaseOwners.remove(leaseOwner) + capability.activatedLeaseOwners.remove(leaseOwner) + let activationWaiter = capability.activationWaiters.removeValue( + forKey: leaseOwner + ) + activationWaiter?.fulfill(.failure(CancellationError())) + + guard case .open = state else { + capabilities.states[key] = capability + capabilities.removeEmptyState(for: key) + return + } + + guard capability.desiredCount == 0 else { + capabilities.states[key] = capability + return + } + + let cleanup: ReplyPromise? + switch capability.physical { + case .inactive: + cleanup = nil + case .enabled: + let promise = ReplyPromise() + capability.releaseWaiters[leaseOwner] = promise + cleanup = promise + case let .enabling(generation, operationID, _): + let promise = ReplyPromise() + capability.releaseWaiters[leaseOwner] = promise + capability.physical = .enabling( + generation: generation, + operationID: operationID, + mustDisableAfterEnable: true + ) + cleanup = promise + case .disabling: + let promise = ReplyPromise() + capability.releaseWaiters[leaseOwner] = promise + cleanup = promise + } + capabilities.states[key] = capability + await reconcileCapability(for: key) + try await cleanup?.valueIgnoringCancellation() + capabilities.removeEmptyState(for: key) + } + + private func abandonCapabilityLease( + _ leaseOwner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async { + guard var capability = capabilities.states[key] else { + return + } + capability.leaseOwners.remove(leaseOwner) + capability.failedLeaseOwners.remove(leaseOwner) + capability.activatedLeaseOwners.remove(leaseOwner) + capability.activationWaiters.removeValue(forKey: leaseOwner) + capability.releaseWaiters.removeValue(forKey: leaseOwner) + capabilities.states[key] = capability + await reconcileCapability(for: key) + capabilities.removeEmptyState(for: key) + } + + private func acquireElementPickerMode( + _ owner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async throws { + let generation = generation(for: key.route) + var mode = elementPickerModes[key] + ?? ConnectionElementPickerMode(generation: generation) + if mode.physical.generation != generation { + mode.physical = .inactive(generation) + mode.activatedThrough.removeAll(keepingCapacity: true) + } + precondition( + mode.owners.insert(owner).inserted, + "Duplicate element-picker mode owner." + ) + if case .enabled(generation) = mode.physical { + mode.activatedThrough[owner] = eventSequences.current.sequence + elementPickerModes[key] = mode + return + } + let completion = ReplyPromise() + mode.activationWaiters[owner] = completion + elementPickerModes[key] = mode + await reconcileElementPickerMode(for: key) + do { + try await completion.value() + } catch { + await abandonElementPickerMode(owner, for: key) + throw error + } + } + + private func releaseElementPickerMode( + _ owner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async throws { + guard var mode = elementPickerModes[key], + mode.owners.remove(owner) != nil else { + return + } + mode.activatedThrough[owner] = nil + if let activation = mode.activationWaiters.removeValue(forKey: owner) { + activation.fulfill(.failure(CancellationError())) + } + + let completion: ReplyPromise? + switch mode.physical { + case .inactive: + completion = nil + case .enabled where !mode.owners.isEmpty: + completion = nil + case .enabling, .enabled, .disabling: + let promise = ReplyPromise() + mode.releaseWaiters[owner] = promise + completion = promise + } + elementPickerModes[key] = mode + await reconcileElementPickerMode(for: key) + try await completion?.valueIgnoringCancellation() + removeEmptyElementPickerMode(for: key) + } + + private func abandonElementPickerMode( + _ owner: ConnectionCapabilityLeaseOwner, + for key: ConnectionCapabilityKey + ) async { + guard var mode = elementPickerModes[key] else { + return + } + mode.owners.remove(owner) + mode.activatedThrough[owner] = nil + mode.activationWaiters.removeValue(forKey: owner) + mode.releaseWaiters.removeValue(forKey: owner) + elementPickerModes[key] = mode + await reconcileElementPickerMode(for: key) + removeEmptyElementPickerMode(for: key) + } + + private func reconcileElementPickerMode( + for key: ConnectionCapabilityKey + ) async { + guard isOpen, var mode = elementPickerModes[key] else { + return + } + let generation = generation(for: key.route) + if mode.physical.generation != generation { + mode.physical = .inactive(generation) + mode.activatedThrough.removeAll(keepingCapacity: true) + elementPickerModes[key] = mode + } + + switch mode.physical { + case .inactive where !mode.owners.isEmpty: + mode.physical = .enabling(generation) + elementPickerModes[key] = mode + let result: Result + do { + result = .success( + try await sendElementPickerModeCommand( + enabled: true, + key: key, + generation: generation + ) + ) + } catch { + result = .failure( + Self.mapElementPickerModeError(error, enabled: true) + ) + } + await completeElementPickerModeTransition( + enabled: true, + key: key, + generation: generation, + result: result + ) + + case .enabled where mode.owners.isEmpty: + mode.physical = .disabling(generation) + elementPickerModes[key] = mode + let result: Result + do { + result = .success( + try await sendElementPickerModeCommand( + enabled: false, + key: key, + generation: generation + ) + ) + } catch { + result = .failure( + Self.mapElementPickerModeError(error, enabled: false) + ) + } + await completeElementPickerModeTransition( + enabled: false, + key: key, + generation: generation, + result: result + ) + + case .inactive, .enabling, .enabled, .disabling: + break + } + } + + private func completeElementPickerModeTransition( + enabled: Bool, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + result: Result + ) async { + guard var mode = elementPickerModes[key], + mode.physical.generation == generation else { + return + } + if enabled { + guard case .enabling = mode.physical else { + return + } + switch result { + case let .success(reply): + mode.physical = .enabled(generation) + for owner in mode.owners { + mode.activatedThrough[owner] = reply.receivedSequence + } + let waiters = Array(mode.activationWaiters.values) + mode.activationWaiters.removeAll() + elementPickerModes[key] = mode + for waiter in waiters { + waiter.fulfill(.success(())) + } + await reconcileElementPickerMode(for: key) + case let .failure(error): + mode.physical = .inactive(generation) + let activationWaiters = Array(mode.activationWaiters.values) + mode.activationWaiters.removeAll() + let releaseWaiters = Array(mode.releaseWaiters.values) + mode.releaseWaiters.removeAll() + elementPickerModes[key] = mode + for waiter in activationWaiters { + waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + waiter.fulfill(.success(())) + } + } + return + } + + guard case .disabling = mode.physical else { + return + } + switch result { + case .success: + mode.physical = .inactive(generation) + let waiters = Array(mode.releaseWaiters.values) + mode.releaseWaiters.removeAll() + elementPickerModes[key] = mode + for waiter in waiters { + waiter.fulfill(.success(())) + } + await reconcileElementPickerMode(for: key) + case let .failure(error): + mode.physical = .enabled(generation) + let waiters = Array(mode.releaseWaiters.values) + mode.releaseWaiters.removeAll() + elementPickerModes[key] = mode + for waiter in waiters { + waiter.fulfill(.failure(error)) + } + } + } + + private func sendElementPickerModeCommand( + enabled: Bool, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation + ) async throws -> ProtocolCommand.Result { + guard generation == currentPageGeneration, + case .currentPage = key.route.storage else { + throw WebInspectorProxyError.staleIdentifier + } + let targetID = try currentMainPageTarget() + let command = ProtocolCommand( + domain: .dom, + method: "DOM.setInspectModeEnabled", + routing: .target(targetID), + parametersData: try elementPickerModeParametersData( + enabled: enabled + ) + ) + return try await sendTarget( + command, + targetID: targetID, + owner: .elementPickerMode( + key: key, + generation: generation, + documentEpoch: modelDocumentEpoch(for: targetID), + enabled: enabled + ) + ) + } + + private func elementPickerShouldDeliver( + _ eventSequence: UInt64, + to owner: ConnectionCapabilityLeaseOwner, + key: ConnectionCapabilityKey + ) -> Bool { + guard let through = elementPickerModes[key]?.activatedThrough[owner] else { + return false + } + return eventSequence > through + } + + private func removeEmptyElementPickerMode( + for key: ConnectionCapabilityKey + ) { + guard let mode = elementPickerModes[key], + mode.owners.isEmpty, + mode.activationWaiters.isEmpty, + mode.releaseWaiters.isEmpty, + case .inactive = mode.physical else { + return + } + elementPickerModes[key] = nil + } + + private func markElementPickerModeInactive( + for key: ConnectionCapabilityKey + ) { + guard var mode = elementPickerModes[key] else { + return + } + mode.physical = .inactive(generation(for: key.route)) + mode.activatedThrough.removeAll(keepingCapacity: false) + elementPickerModes[key] = mode + removeEmptyElementPickerMode(for: key) + } + + private func reconcileCapability(for key: ConnectionCapabilityKey) async { + guard isOpen, var capability = capabilities.states[key] else { + return + } + + let expectedGeneration = generation(for: key.route) + guard capability.physical.generation == expectedGeneration else { + capability.physical = .inactive(generation: expectedGeneration) + capabilities.states[key] = capability + return await reconcileCapability(for: key) + } + + if key.domain == .dom { + if capability.desiredCount > 0 { + capability.physical = .enabled(generation: expectedGeneration) + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) + let waiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + capabilities.states[key] = capability + for waiter in waiters { + waiter.fulfill(.success(())) + } + } else { + capability.physical = .inactive(generation: expectedGeneration) + let waiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + for waiter in waiters { + waiter.fulfill(.success(())) + } + } + capabilities.removeEmptyState(for: key) + return + } + + switch capability.physical { + case .inactive where capability.desiredCount > 0: + guard (try? requireAvailableTarget(for: key.route)) != nil else { + capabilities.states[key] = capability + return + } + startCapabilityEnable(for: key, generation: expectedGeneration) + case .enabled where capability.desiredCount == 0: + startCapabilityDisable(for: key, generation: expectedGeneration) + case .inactive, .enabling, .enabled, .disabling: + capabilities.states[key] = capability + } + } + + private func startCapabilityEnable( + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation + ) { + guard var capability = capabilities.states[key] else { + return + } + let operationID = capabilities.allocateOperationID() + capability.physical = .enabling( + generation: generation, + operationID: operationID, + mustDisableAfterEnable: false + ) + capabilities.states[key] = capability + startCapabilityTask( + id: operationID, + key: key, + generation: generation, + action: .enable + ) + } + + private func startCapabilityDisable( + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation + ) { + guard var capability = capabilities.states[key] else { + return + } + let operationID = capabilities.allocateOperationID() + capability.physical = .disabling(generation: generation, operationID: operationID) + capabilities.states[key] = capability + startCapabilityTask( + id: operationID, + key: key, + generation: generation, + action: .disable + ) + } + + private enum CapabilityWireAction: Equatable, Sendable { + case enable + case disable + } + + private func startCapabilityTask( + id: UInt64, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + action: CapabilityWireAction + ) { + let operation: ConnectionOwnedCommandOperation + do { + operation = try makeCapabilityCommandOperation( + action, + for: key, + generation: generation, + operationID: id + ) + } catch { + let result: Result = .failure( + Self.mapCapabilityError(error, action: action, domain: key.domain) + ) + let task = Task { [weak self] in + _ = await self?.completeCapabilityOperation( + id: id, + key: key, + generation: generation, + action: action, + result: result + ) + } + precondition(capabilityTasks[id] == nil, "A capability operation identifier already has an owner.") + capabilityTasks[id] = ConnectionCapabilityTask( + task: task, + pendingReplyOwnership: nil + ) + return + } + + let task = Task { [weak self, operation] in + let wireResult: Result + do { + try await operation.value() + wireResult = .success(()) + } catch { + wireResult = .failure( + Self.mapCapabilityError( + error, + action: action, + domain: key.domain + ) + ) + } + let result = await self?.initializeInspectorAfterEnableIfNeeded( + id: id, + key: key, + generation: generation, + action: action, + wireResult: wireResult + ) ?? .failure(WebInspectorProxyError.closed) + _ = await self?.completeCapabilityOperation( + id: id, + key: key, + generation: generation, + action: action, + result: result + ) + } + precondition(capabilityTasks[id] == nil, "A capability operation identifier already has an owner.") + capabilityTasks[id] = ConnectionCapabilityTask( + task: task, + pendingReplyOwnership: operation.pendingReplyOwnership + ) + } + + private func makeCapabilityCommandOperation( + _ action: CapabilityWireAction, + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) throws -> ConnectionOwnedCommandOperation { + let method: String + switch action { + case .enable: + method = "\(key.domain.rawValue).enable" + case .disable: + method = "\(key.domain.rawValue).disable" + } + return try makeCapabilityCommandOperation( + method: method, + for: key, + generation: generation, + operationID: operationID + ) + } + + private func makeCapabilityCommandOperation( + method: String, + for key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64, + publishesReplay: Bool = true + ) throws -> ConnectionOwnedCommandOperation { + let targetID: ProtocolTarget.ID + switch key.route.storage { + case let .target(rawValue): + targetID = ProtocolTarget.ID(rawValue) + guard targetRegistry.containsTarget(targetID) else { + throw TransportSession.Error.missingTarget(targetID) + } + case .currentPage: + targetID = try currentMainPageTarget() + } + + let domain = protocolDomain(for: key.domain) + let innerCommandID = allocateCommandID() + let outerCommandID = allocateCommandID() + let replyKey = TransportSession.ReplyKey( + targetID: targetID, + commandID: innerCommandID + ) + let pendingKey = TransportSession.PendingKey.target(replyKey) + let message = try TransportMessageParser.makeCommandString( + id: innerCommandID, + method: method, + parametersData: Data("{}".utf8) + ) + let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( + id: outerCommandID, + targetIdentifier: targetID.rawValue, + message: message + ) + let promise = ReplyPromise() + let pendingReply = if publishesReplay { + TransportSession.PendingReply.capability( + domain: domain, + method: method, + targetID: targetID, + promise: promise, + key: key, + generation: generation, + operationID: operationID + ) + } else { + TransportSession.PendingReply.capabilityAuxiliary( + domain: domain, + method: method, + targetID: targetID, + promise: promise, + key: key, + generation: generation, + operationID: operationID + ) + } + replyStore.insertTargetReply( + pendingReply, + key: replyKey, + rootWrapperID: outerCommandID + ) + let pendingReplyOwnership = ConnectionPendingReplyOwnership( + key: pendingKey, + purpose: pendingReply.purpose + ) + + let timeoutAction: (@Sendable () async -> Void)? + if let responseTimeout { + let timeoutSleep = self.timeoutSleep + let responseTimeoutDidFire = self.responseTimeoutDidFire + timeoutAction = { [weak self] in + do { + try await timeoutSleep(responseTimeout) + } catch { + return + } + await self?.failPendingReplyFromTimeout( + pendingKey, + error: TransportSession.Error.replyTimeout( + method: method, + targetID: targetID + ) + ) + await responseTimeoutDidFire() + } + } else { + timeoutAction = nil + } + + return ConnectionOwnedCommandOperation( + backend: backend, + message: wrapperMessage, + promise: promise, + pendingReplyOwnership: pendingReplyOwnership, + timeoutAction: timeoutAction + ) + } + + private func initializeInspectorAfterEnableIfNeeded( + id: UInt64, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + action: CapabilityWireAction, + wireResult: Result + ) async -> Result { + guard case .success = wireResult, + action == .enable, + key.domain == .inspector else { + return wireResult + } + if inspectorInitializedGeneration[key] == generation { + return wireResult + } + guard let capability = capabilities.states[key], + case let .enabling( + activeGeneration, + operationID, + mustDisableAfterEnable + ) = capability.physical, + activeGeneration == generation, + operationID == id, + !mustDisableAfterEnable, + capability.desiredCount > 0 else { + return wireResult + } + + let operation: ConnectionOwnedCommandOperation + do { + operation = try makeCapabilityCommandOperation( + method: "Inspector.initialized", + for: key, + generation: generation, + operationID: id, + publishesReplay: false + ) + } catch { + return .failure( + Self.mapCapabilityError( + error, + action: action, + domain: key.domain + ) + ) + } + guard var task = capabilityTasks[id] else { + return .failure(WebInspectorProxyError.closed) + } + task.pendingReplyOwnership = operation.pendingReplyOwnership + capabilityTasks[id] = task + do { + try await operation.value() + inspectorInitializedGeneration[key] = generation + return .success(()) + } catch { + return .failure( + Self.mapCapabilityError( + error, + action: action, + domain: key.domain + ) + ) + } + } + + private func completeCapabilityOperation( + id: UInt64, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + action: CapabilityWireAction, + result: Result + ) async { + if let ownership = capabilityTasks.removeValue(forKey: id)?.pendingReplyOwnership, + let pending = replyStore.removePendingReply(ownership.key) { + precondition( + pending.purpose == ownership.purpose, + "A capability task attempted to remove a reply owned by another purpose." + ) + } + guard isOpen, var capability = capabilities.states[key] else { + return + } + + switch (action, capability.physical) { + case let (.enable, .enabling(activeGeneration, operationID, mustDisableAfterEnable)) + where activeGeneration == generation && operationID == id: + switch result { + case .success: + capability.physical = .enabled(generation: generation) + capabilities.states[key] = capability + if mustDisableAfterEnable, capability.desiredCount == 0 { + startCapabilityDisable(for: key, generation: generation) + return + } + if key.domain == .inspector { + await reconcileElementPickerMode(for: key) + } + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) + let waiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + let releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + for waiter in waiters { + waiter.fulfill(.success(())) + } + for waiter in releaseWaiters { + waiter.fulfill(.success(())) + } + case let .failure(error): + let activeLeaseFailed = capability.hasActivatedDesiredLease + let wireStateIsKnownInactive = Self.enableFailureProvesInactive(error) + let failedIDs = Set(capability.activationWaiters.keys) + capability.failedLeaseOwners.formUnion(failedIDs) + let activationWaiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + capability.physical = .inactive(generation: generation) + let releaseWaiters: [ReplyPromise] + if capability.desiredCount == 0 { + releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + } else { + releaseWaiters = [] + } + let proposedTerminalCause: TerminalCause? = if wireStateIsKnownInactive == false + || (activeLeaseFailed && Self.isCommandRejection(error)) { + Self.terminalCauseForUncertainEnableFailure( + error, + domain: key.domain, + wasReenable: activeLeaseFailed + ) + } else { + nil + } + let claimedTerminalCause: TerminalCause? + if let proposedTerminalCause { + // Claim terminal ownership before resuming any waiter so + // actor reentrancy cannot admit a duplicate enable while + // the failed wire state is unknown. + let cause = terminalClaim.claim(proposedTerminalCause).cause + claimedTerminalCause = cause + state = .closing + } else { + claimedTerminalCause = nil + } + capabilities.states[key] = capability + for waiter in activationWaiters { + waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + waiter.fulfill(.success(())) + } + if let claimedTerminalCause { + await finishClaimedTermination(claimedTerminalCause) + } + } + + case let (.disable, .disabling(activeGeneration, operationID)) + where activeGeneration == generation && operationID == id: + switch result { + case .success: + capability.physical = .inactive(generation: generation) + let releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + await reconcileCapability(for: key) + for waiter in releaseWaiters { + waiter.fulfill(.success(())) + } + case let .failure(error): + if Self.isCommandRejection(error) { + // A rejected disable proves that the command did not + // deactivate the physical domain. Retain the enabled state + // so a late lease can use it and a future final release can + // retry cleanup without sending a duplicate enable. + capability.physical = .enabled(generation: generation) + capability.activatedLeaseOwners.formUnion(capability.activationWaiters.keys) + let activationWaiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + let releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + for waiter in activationWaiters { + waiter.fulfill(.success(())) + } + for waiter in releaseWaiters { + waiter.fulfill(.failure(error)) + } + return + } + + if Self.isPageUnavailable(error) { + // Target disappearance normally supersedes the operation + // before its completion reaches this branch. If the local + // target lookup wins that race, the vanished target makes + // cleanup complete without establishing reusable wire state. + let failedIDs = Set(capability.activationWaiters.keys) + capability.failedLeaseOwners.formUnion(failedIDs) + capability.physical = .inactive(generation: generation) + let activationWaiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + let releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + for waiter in activationWaiters { + waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + waiter.fulfill(.success(())) + } + return + } + + let proposedTerminalCause = Self.terminalCauseForUncertainDisableFailure( + error, + domain: key.domain + ) + // Claim terminal ownership before resuming any waiter so actor + // reentrancy cannot admit a lease against uncertain wire state. + let claimedTerminalCause = terminalClaim.claim(proposedTerminalCause).cause + state = .closing + let activationWaiters = Array(capability.activationWaiters.values) + capability.activationWaiters.removeAll() + let releaseWaiters = Array(capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capabilities.states[key] = capability + for waiter in activationWaiters { + waiter.fulfill(.failure(error)) + } + for waiter in releaseWaiters { + waiter.fulfill(.failure(error)) + } + await finishClaimedTermination(claimedTerminalCause) } - return try await sendTarget(command, targetID: resolvedTarget) - } - } - @discardableResult - package func receiveRootMessage(_ message: String) async -> UInt64 { - guard !closed else { - return eventSequences.current.sequence + default: + // Completion from an older generation or superseded operation can + // release its task, but it cannot mutate current physical state. + return } - inboundMessageQueue.append(message) - await drainInboundMessages() - return eventSequences.current.sequence } - package func detach() async { - guard !closed else { - return + private nonisolated static func mapCapabilityError( + _ error: any Swift.Error, + action: CapabilityWireAction, + domain: WebInspectorProxyEventDomain + ) -> any Swift.Error { + if let proxyError = error as? WebInspectorProxyError { + return proxyError } - closed = true - for pending in replyStore.pendingReplies { - await pending.promise.fulfill(.failure(TransportSession.Error.transportClosed)) + let method = "\(domain.rawValue).\(action == .enable ? "enable" : "disable")" + guard let transportError = error as? TransportSession.Error else { + return WebInspectorProxyError.transportFailure(String(describing: error)) } - for waiter in mainPageTargetWaiterStore.removeAll() { - await waiter.fulfill(.failure(TransportSession.Error.transportClosed)) + switch transportError { + case .transportClosed: + return WebInspectorProxyError.closed + case let .transportFailure(message): + return WebInspectorProxyError.transportFailure(message) + case let .remoteError(_, _, message): + return WebInspectorProxyError.commandRejected(method: method, message: message) + case .missingMainPageTarget, .missingTarget: + return WebInspectorProxyError.pageUnavailable + case .malformedMessage: + return WebInspectorProxyError.protocolViolation("Malformed reply for \(method).") + case .replyTimeout: + return WebInspectorProxyError.timeout(domain: domain.rawValue, method: action == .enable ? "enable" : "disable") } - replyStore.removeAll() - provisionalTargetMessageStore.removeAll() - eventSubscribers.finishAndRemoveAll() - await backend.detach() } - package func waitForCurrentMainPageTarget(timeout: Duration? = nil) async throws -> TransportSession.MainPageTarget { - guard !closed else { - throw TransportSession.Error.transportClosed + private nonisolated static func mapElementPickerModeError( + _ error: any Swift.Error, + enabled: Bool + ) -> any Swift.Error { + if let proxyError = error as? WebInspectorProxyError { + return proxyError } - if let currentMainPageTargetID = targetRegistry.currentMainPageTargetID { - return TransportSession.MainPageTarget( - targetID: currentMainPageTargetID, - receivedSequence: eventSequences.current.sequence + let method = "DOM.setInspectModeEnabled" + guard let transportError = error as? TransportSession.Error else { + return WebInspectorProxyError.transportFailure( + String(describing: error) + ) + } + switch transportError { + case .transportClosed: + return WebInspectorProxyError.closed + case let .transportFailure(message): + return WebInspectorProxyError.transportFailure(message) + case let .remoteError(_, _, message): + return WebInspectorProxyError.commandRejected( + method: method, + message: message + ) + case .missingMainPageTarget, .missingTarget: + return WebInspectorProxyError.pageUnavailable + case .malformedMessage: + return WebInspectorProxyError.protocolViolation( + "Malformed reply for \(method)." + ) + case .replyTimeout: + return WebInspectorProxyError.timeout( + domain: "DOM", + method: enabled ? "setInspectModeEnabled(true)" : "setInspectModeEnabled(false)" ) } + } - let waiter = mainPageTargetWaiterStore.insert() + /// Maps a known model-domain activation rejection without deriving the + /// domain from a wire method string. Transport, protocol, page-lifecycle, + /// and cancellation failures retain their existing categories because + /// their recovery is connection- rather than model-domain-specific. + private nonisolated static func modelFeedActivationError( + _ error: any Swift.Error, + domain: ModelDomain + ) -> any Swift.Error { + guard let proxyError = error as? WebInspectorProxyError, + case let .commandRejected(_, message) = proxyError else { + return error + } + return ConnectionModelFeedError.bootstrapFailed( + domain: domain, + message: message + ) + } - let timeoutTask: Task? = timeout.map { timeout in - let timeoutSleep = self.timeoutSleep - return Task { - do { - try await timeoutSleep(timeout) - } catch { - return - } - await self.failMainPageTargetWaiter(waiter.id, error: TransportSession.Error.missingMainPageTarget) - } + private nonisolated static func enableFailureProvesInactive( + _ error: any Swift.Error + ) -> Bool { + guard let error = error as? WebInspectorProxyError else { + return false } - defer { - timeoutTask?.cancel() + switch error { + case .commandRejected, .pageUnavailable: + return true + case .unsupported, .attachFailed, .closed, .staleIdentifier, + .disconnected, .commandFailed, .protocolViolation, + .eventBufferOverflow, .connectionInUse, + .transportFailure, .timeout: + return false } + } - do { - return try await withTaskCancellationHandler { - try await waiter.promise.value() - } onCancel: { - Task { - await self.failMainPageTargetWaiter(waiter.id, error: CancellationError()) - } - } - } catch { - mainPageTargetWaiterStore.remove(id: waiter.id) - throw error + private nonisolated static func isCommandRejection( + _ error: any Swift.Error + ) -> Bool { + guard let error = error as? WebInspectorProxyError, + case .commandRejected = error else { + return false } + return true } - package func snapshot() -> TransportSession.Snapshot { - TransportSession.Snapshot( - currentMainPageTargetID: targetRegistry.currentMainPageTargetID, - targetsByID: targetRegistry.targetsByID, - frameTargetIDsByFrameID: targetRegistry.frameTargetIDsByFrameID, - executionContextsByKey: runtimeContextRegistry.contextsByKey, - pendingRootReplyIDs: replyStore.pendingRootReplyIDs, - pendingTargetReplyKeys: replyStore.pendingTargetReplyKeys + private nonisolated static func isPageUnavailable( + _ error: any Swift.Error + ) -> Bool { + guard let error = error as? WebInspectorProxyError, + case .pageUnavailable = error else { + return false + } + return true + } + + private nonisolated static func terminalCauseForUncertainEnableFailure( + _ error: any Swift.Error, + domain: WebInspectorProxyEventDomain, + wasReenable: Bool + ) -> TerminalCause { + if let error = error as? WebInspectorProxyError, + case let .protocolViolation(message) = error { + return .protocolViolation(message) + } + let action = wasReenable ? "re-enable" : "enable" + return .fatal( + "Failed to \(action) \(domain.rawValue) with an uncertain wire state: \(error)" ) } - package func targetID(forExecutionContext key: RuntimeContext.Key) -> ProtocolTarget.ID? { - runtimeContextRegistry.targetID(for: key) + private nonisolated static func terminalCauseForUncertainDisableFailure( + _ error: any Swift.Error, + domain: WebInspectorProxyEventDomain + ) -> TerminalCause { + if let error = error as? WebInspectorProxyError, + case let .protocolViolation(message) = error { + return .protocolViolation(message) + } + return .fatal( + "Failed to disable \(domain.rawValue) with an uncertain wire state: \(error)" + ) } - package func targetID(forFrameID frameID: ProtocolFrame.ID) -> ProtocolTarget.ID? { - targetRegistry.targetID(forFrameID: frameID) + private func protocolDomain(for domain: WebInspectorProxyEventDomain) -> ProtocolDomain { + switch domain { + case .target: + .target + case .dom: + .dom + case .inspector: + .inspector + case .css: + .css + case .network: + .network + case .console: + .console + case .runtime: + .runtime + case .page: + .page + } } - private func sendRoot(_ command: ProtocolCommand) async throws -> ProtocolCommand.Result { + private func sendRoot( + _ command: ProtocolCommand, + admission: ConnectionDirectCommandAdmission + ) async throws -> ProtocolCommand.Result { let commandID = allocateCommandID() let promise = ReplyPromise() - replyStore.insertRootReply(TransportSession.PendingReply( + try Task.checkCancellation() + let message = try TransportMessageParser.makeCommandString( + id: commandID, + method: command.method, + parametersData: command.parametersData + ) + let pending = makeDirectPendingReply( domain: command.domain, method: command.method, targetID: nil, promise: promise, - hasBufferedProvisionalResponse: false - ), commandID: commandID) + admission: admission + ) + replyStore.insertRootReply(pending, commandID: commandID) do { - try Task.checkCancellation() - let message = try TransportMessageParser.makeCommandString( - id: commandID, - method: command.method, - parametersData: command.parametersData - ) try await backend.sendJSONString(message) try Task.checkCancellation() } catch { - await failPendingReply(.root(commandID), error: error) + failPendingReply(.root(commandID), error: error) throw error } return try await awaitReply( @@ -221,35 +3736,49 @@ package actor TransportSession { private func sendTarget( _ command: ProtocolCommand, - targetID: ProtocolTarget.ID + targetID: ProtocolTarget.ID, + owner: ConnectionTargetCommandOwner ) async throws -> ProtocolCommand.Result { let innerCommandID = allocateCommandID() let outerCommandID = allocateCommandID() let key = TransportSession.ReplyKey(targetID: targetID, commandID: innerCommandID) let promise = ReplyPromise() - replyStore.insertTargetReply(TransportSession.PendingReply( - domain: command.domain, + try Task.checkCancellation() + let message = try TransportMessageParser.makeCommandString( + id: innerCommandID, method: command.method, - targetID: targetID, - promise: promise, - hasBufferedProvisionalResponse: false - ), key: key, rootWrapperID: outerCommandID) - do { - try Task.checkCancellation() - let message = try TransportMessageParser.makeCommandString( - id: innerCommandID, + parametersData: command.parametersData + ) + let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( + id: outerCommandID, + targetIdentifier: targetID.rawValue, + message: message + ) + let pending: TransportSession.PendingReply = switch owner { + case let .direct(admission): + makeDirectPendingReply( + domain: command.domain, method: command.method, - parametersData: command.parametersData + targetID: targetID, + promise: promise, + admission: admission ) - let wrapperMessage = try TransportMessageParser.makeTargetWrapperCommandString( - id: outerCommandID, - targetIdentifier: targetID.rawValue, - message: message + case let .elementPickerMode(key, generation, documentEpoch, enabled): + TransportSession.PendingReply.elementPickerMode( + targetID: targetID, + promise: promise, + key: key, + generation: generation, + documentEpoch: documentEpoch, + enabled: enabled ) + } + replyStore.insertTargetReply(pending, key: key, rootWrapperID: outerCommandID) + do { try await backend.sendJSONString(wrapperMessage) try Task.checkCancellation() } catch { - await failPendingReply(.target(key), error: error) + failPendingReply(.target(key), error: error) throw error } return try await awaitReply( @@ -260,6 +3789,23 @@ package actor TransportSession { ) } + private func makeDirectPendingReply( + domain: ProtocolDomain, + method: String, + targetID: ProtocolTarget.ID?, + promise: ReplyPromise, + admission: ConnectionDirectCommandAdmission + ) -> TransportSession.PendingReply { + TransportSession.PendingReply.direct( + domain: domain, + method: method, + targetID: targetID, + promise: promise, + bindingGeneration: admission.bindingGeneration, + documentEpoch: admission.documentEpoch + ) + } + private func transportLocalResult( for command: ProtocolCommand, targetID: ProtocolTarget.ID @@ -287,11 +3833,24 @@ package actor TransportSession { inboundMessageQueue.finishDraining() } - while let rawMessage = inboundMessageQueue.popNext() { - guard let parsed = try? await TransportMessageParser.parse(rawMessage) else { - continue + while isOpen, let rawMessage = inboundMessageQueue.popNext() { + let parsed: ParsedProtocolMessage + do { + parsed = try await messageParser(rawMessage) + } catch { + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Malformed root protocol message.")) + return + } + guard isOpen else { + return } await handleRootMessage(parsed) + guard isOpen else { + return + } } } @@ -302,7 +3861,7 @@ package actor TransportSession { targetID: ProtocolTarget.ID? ) async throws -> ProtocolCommand.Result { if Task.isCancelled { - await failPendingReply(key, error: CancellationError()) + failPendingReply(key, error: CancellationError()) } let timeoutTask: Task? = responseTimeout.map { responseTimeout in let timeoutSleep = self.timeoutSleep @@ -324,13 +3883,7 @@ package actor TransportSession { timeoutTask?.cancel() } do { - return try await withTaskCancellationHandler { - try await promise.value() - } onCancel: { - Task { - await self.failPendingReply(key, error: CancellationError()) - } - } + return try await promise.value() } catch { removePendingReply(key) throw error @@ -338,18 +3891,21 @@ package actor TransportSession { } private func handleRootMessage(_ parsed: ParsedProtocolMessage) async { + guard isOpen else { + return + } if let id = parsed.id, let key = replyStore.takeTargetReplyKey(forRootWrapperID: id) { if parsed.errorMessage != nil, let pending = replyStore.removeTargetReply(for: key) { - await resolve(pending, parsed: parsed) + resolve(pending, key: .target(key), parsed: parsed) } return } if let id = parsed.id, let pending = replyStore.removeRootReply(commandID: id) { - await resolve(pending, parsed: parsed) + resolve(pending, key: .root(id), parsed: parsed) return } @@ -359,39 +3915,87 @@ package actor TransportSession { if method == "Target.dispatchMessageFromTarget" { guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData) else { + handoffTermination(.protocolViolation("Malformed Target.dispatchMessageFromTarget payload.")) + return + } + let targetMessage: ParsedProtocolMessage + do { + targetMessage = try await messageParser(dispatch.message) + } catch { + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Malformed target protocol message.")) return } - guard let targetMessage = try? await TransportMessageParser.parse(dispatch.message) else { + guard isOpen else { return } await handleTargetMessage(targetMessage, targetID: dispatch.targetId) return } + let domain = ProtocolDomain(method: method) + // Root event sequences are reserved before registry mutation. Target + // snapshot watermarks can therefore subsume the exact lifecycle event + // that installed their physical binding without an N/N+1 gap. + let eventSequence = eventSequences.recordEvent(domain: domain) let targetID = targetIDForRootEvent(method: method, paramsData: parsed.paramsData) let sourceTargetID = sourceTargetIDForRootEvent(method: method, targetID: targetID) let destroyedCurrentMainPageTarget = method == "Target.targetDestroyed" && targetID != nil && targetID == targetRegistry.currentMainPageTargetID - let pendingStyleSheetAddedEvents = await updateRegistryFromRootEvent( - method: method, - targetID: targetID, - sourceTargetID: sourceTargetID, - paramsData: parsed.paramsData - ) - await emit( - domain: ProtocolDomain(method: method), + let mutation: RootEventMutation + do { + mutation = try updateRegistryFromRootEvent( + method: method, + targetID: targetID, + sourceTargetID: sourceTargetID, + paramsData: parsed.paramsData, + eventSequence: eventSequence.sequence + ) + } catch { + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Failed to decode \(method): \(error)")) + return + } + guard isOpen else { + return + } + // Registry mutation and model-feed snapshot/delta publication above are + // one synchronous prefix. Preserve the existing direct-consumer order + // by reconciling capability ownership and disappearance effects before + // publishing the corresponding root event. + await completeRootEventMutation(mutation) + guard isOpen else { + return + } + let emission = emit( + domain: domain, method: method, targetID: targetID, sourceTargetID: sourceTargetID, paramsData: parsed.paramsData, - destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget + destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget, + reservedEventSequence: eventSequence ) - await emitResolvedStyleSheetAddedEvents(pendingStyleSheetAddedEvents) + await completeEventEmissionEffects(emission) + guard isOpen else { + return + } + await emitResolvedStyleSheetAddedEvents(mutation.pendingStyleSheetEvents) + guard isOpen else { + return + } await dispatchCommittedProvisionalTargetMessagesIfNeeded(method: method, paramsData: parsed.paramsData) } private func handleTargetMessage(_ parsed: ParsedProtocolMessage, targetID: ProtocolTarget.ID) async { + guard isOpen else { + return + } if targetRegistry.target(for: targetID)?.isProvisional == true { markTargetReplyAsBufferedIfNeeded(parsed, targetID: targetID) provisionalTargetMessageStore.append(parsed, for: targetID) @@ -401,7 +4005,7 @@ package actor TransportSession { if let id = parsed.id { let key = TransportSession.ReplyKey(targetID: targetID, commandID: id) if let pending = replyStore.removeTargetReply(for: key) { - await resolve(pending, parsed: parsed) + resolve(pending, key: .target(key), parsed: parsed) return } } @@ -411,8 +4015,21 @@ package actor TransportSession { } if method == "Target.dispatchMessageFromTarget" { - guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData), - let targetMessage = try? await TransportMessageParser.parse(dispatch.message) else { + guard let dispatch = try? TransportMessageParser.decode(TargetDispatchParams.self, from: parsed.paramsData) else { + handoffTermination(.protocolViolation("Malformed nested Target.dispatchMessageFromTarget payload.")) + return + } + let targetMessage: ParsedProtocolMessage + do { + targetMessage = try await messageParser(dispatch.message) + } catch { + guard isOpen else { + return + } + handoffTermination(.protocolViolation("Malformed nested Target.dispatchMessageFromTarget payload.")) + return + } + guard isOpen else { return } await handleTargetMessage(targetMessage, targetID: dispatch.targetId) @@ -430,18 +4047,24 @@ package actor TransportSession { sourceTargetID: targetID, paramsData: parsed.paramsData ) - await emit( + let emission = emit( domain: ProtocolDomain(method: method), method: method, targetID: emittedTargetID, sourceTargetID: targetID, paramsData: parsed.paramsData ) + await completeEventEmissionEffects(emission) } - private func resolve(_ pending: TransportSession.PendingReply, parsed: ParsedProtocolMessage) async { + private func resolve( + _ pending: TransportSession.PendingReply, + key: TransportSession.PendingKey, + parsed: ParsedProtocolMessage + ) { + validateReplyOwnership(pending, key: key) if let errorMessage = parsed.errorMessage { - await pending.promise.fulfill( + pending.promise.fulfill( .failure( TransportSession.Error.remoteError( method: pending.method, @@ -453,43 +4076,334 @@ package actor TransportSession { return } let eventSequence = eventSequences.current - await pending.promise.fulfill( - .success( - ProtocolCommand.Result( - domain: pending.domain, - method: pending.method, - targetID: pending.targetID, - receivedSequence: eventSequence.sequence, - receivedDomainSequences: eventSequence.receivedDomainSequences, - resultData: parsed.resultData + let result = ProtocolCommand.Result( + domain: pending.domain, + method: pending.method, + targetID: pending.targetID, + receivedSequence: eventSequence.sequence, + receivedDomainSequences: eventSequence.receivedDomainSequences, + resultData: parsed.resultData + ) + do { + try processSuccessfulReply(result, for: pending) + } catch { + let message = "Failed to decode \(pending.method) reply: \(error)" + handoffTermination(.protocolViolation(message)) + pending.promise.fulfill( + .failure(WebInspectorProxyError.protocolViolation(message)) + ) + return + } + pending.promise.fulfill( + .success(result) + ) + } + + private func validateReplyOwnership( + _ pending: TransportSession.PendingReply, + key: TransportSession.PendingKey + ) { + switch pending.purpose { + case .direct: + break + case let .elementPickerMode(key, generation, _, enabled): + precondition( + pending.method == "DOM.setInspectModeEnabled", + "An element-picker reply has the wrong command owner." + ) + guard let mode = elementPickerModes[key], + mode.physical.generation == generation else { + preconditionFailure( + "An element-picker reply has no matching generation owner." + ) + } + switch (enabled, mode.physical) { + case (true, .enabling), (false, .disabling): + break + case (true, _), (false, _): + preconditionFailure( + "An element-picker reply does not match its active transition." + ) + } + case let .modelCommand(_, operationID): + guard let ownership = modelCommandTasks[operationID]?.pendingReplyOwnership else { + preconditionFailure("A model command reply has no model command task owner.") + } + precondition( + ownership.key == key, + "A model command reply key does not match its operation owner." + ) + precondition( + ownership.purpose == pending.purpose, + "A model command reply purpose does not match its operation owner." + ) + case let .capability(_, _, operationID), + let .capabilityAuxiliary(_, _, operationID): + guard let ownership = capabilityTasks[operationID]?.pendingReplyOwnership else { + preconditionFailure("A capability reply has no capability operation owner.") + } + precondition( + ownership.key == key, + "A capability reply key does not match its operation owner." + ) + precondition( + ownership.purpose == pending.purpose, + "A capability reply purpose does not match its operation owner." + ) + case let .modelBootstrap(_, _, _, _, operationID): + guard let ownership = modelBootstrapTasks[operationID]?.pendingReplyOwnership else { + preconditionFailure("A model bootstrap reply has no task owner.") + } + precondition( + ownership.key == key, + "A model bootstrap reply key does not match its task owner." + ) + precondition( + ownership.purpose == pending.purpose, + "A model bootstrap reply purpose does not match its task owner." + ) + } + } + + /// Synchronous phase boundary for successful reply-side effects. + /// + /// Model replay boundaries remain in this actor's inbound processing slot + /// before `ReplyPromise.fulfill` resumes the waiting capability operation. + private func processSuccessfulReply( + _ result: ProtocolCommand.Result, + for pending: TransportSession.PendingReply + ) throws { + switch pending.purpose { + case .direct, .modelCommand, .capabilityAuxiliary: + // Consumer replies have no internal publication side effect. + break + case let .elementPickerMode(key, generation, _, enabled): + publishElementPickerModeReplyIfCurrent( + result, + key: key, + generation: generation, + enabled: enabled + ) + case let .capability(key, generation, operationID): + publishModelReplayCompletionIfNeeded( + result, + pending: pending, + key: key, + generation: generation, + operationID: operationID + ) + case let .modelBootstrap( + feedID, + generation, + targetID, + documentEpoch, + operationID + ): + try publishDOMBootstrapSnapshotIfCurrent( + result, + feedID: feedID, + generation: generation, + targetID: targetID, + documentEpoch: documentEpoch, + operationID: operationID + ) + } + } + + /// Publishes the picker activation watermark in the same inbound slot as + /// its successful wire reply. WebKit may emit `Inspector.inspect` + /// immediately after that reply; waiting for the command continuation to + /// resume would incorrectly classify the event as pre-activation. + private func publishElementPickerModeReplyIfCurrent( + _ result: ProtocolCommand.Result, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + enabled: Bool + ) { + guard enabled, + result.method == "DOM.setInspectModeEnabled", + result.targetID == targetRegistry.currentMainPageTargetID, + generation == currentPageGeneration, + var mode = elementPickerModes[key], + case .enabling(generation) = mode.physical else { + return + } + for owner in mode.owners { + mode.activatedThrough[owner] = result.receivedSequence + } + elementPickerModes[key] = mode + } + + private func publishDOMBootstrapSnapshotIfCurrent( + _ result: ProtocolCommand.Result, + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + targetID: ProtocolTarget.ID, + documentEpoch: ModelDocumentEpoch, + operationID: UInt64 + ) throws { + guard var registration = modelFeed, + registration.id == feedID, + var bootstrap = registration.domBootstrap, + bootstrap.generation == generation, + var active = bootstrap.activeOperation, + active.id == operationID, + active.targetID == targetID, + active.documentEpoch == documentEpoch else { + // A superseded binding no longer owns this operation. Its task + // completion is ignored by the same generation/operation guards. + return + } + + guard generation == currentPageGeneration, + modelDocumentEpoch(for: targetID) == documentEpoch, + let targetState = bootstrap.targetsByID[targetID], + let physicalRecord = targetRegistry.target(for: targetID), + targetRegistry.isCurrentPageModelTarget(physicalRecord) else { + // Superseded generation, membership, target, or document replies + // are normal stale results and cannot publish into current state. + active.replyDisposition = .stale + bootstrap.activeOperation = active + registration.domBootstrap = bootstrap + modelFeed = registration + return + } + precondition( + result.targetID == targetID, + "A DOM bootstrap reply does not belong to its physical target." + ) + let document = try JSONDecoder() + .decode(ProtocolDOMDocumentResult.self, from: result.resultData) + .proxyRoot() + let projectedDocument = ConnectionEventProjection.projectedDOMBootstrapNode( + document, + target: targetState.target + ) + guard enqueueModelFeedRecord( + .bootstrapSnapshot( + generation: generation, + domain: .dom, + sequence: result.receivedSequence, + payload: .domDocument( + target: targetState.target, + documentEpoch: documentEpoch, + root: projectedDocument ) ) + ) else { + active.replyDisposition = .terminal + bootstrap.activeOperation = active + registration.domBootstrap = bootstrap + modelFeed = registration + return + } + bootstrap.targetsByID[targetID]?.completedEpoch = documentEpoch + active.replyDisposition = .published + bootstrap.activeOperation = active + registration.domBootstrap = bootstrap + modelFeed = registration + } + + private func publishModelReplayCompletionIfNeeded( + _ result: ProtocolCommand.Result, + pending: TransportSession.PendingReply, + key: ConnectionCapabilityKey, + generation: WebInspectorPage.Generation, + operationID: UInt64 + ) { + let enableMethod = "\(key.domain.rawValue).enable" + let disableMethod = "\(key.domain.rawValue).disable" + guard pending.method != disableMethod else { + return + } + precondition( + pending.method == enableMethod, + "A capability reply does not match its enable or disable operation." + ) + guard let capability = capabilities.states[key], + case let .enabling(activeGeneration, activeOperationID, _) = capability.physical, + activeGeneration == generation, + activeOperationID == operationID else { + // A reply from a superseded physical binding cannot publish a + // boundary into the current model generation. + return + } + guard let registration = modelFeed else { + return + } + guard let synchronization = registration.synchronization, + synchronization.generation == generation else { + return + } + let replayDomains = ModelDomain.ordered(registration.configuredDomains).filter { domain in + domain.replayCapability == key.domain + && capability.desiredLeaseOwners.contains(.modelFeed(registration.id, domain)) + } + guard !replayDomains.isEmpty else { + return + } + precondition( + key.route == .currentPage && key.targetID == .currentPage, + "A model feed capability must use the semantic current-page route." + ) + precondition( + generation == currentPageGeneration, + "A current model-feed capability reply must match the current page generation." + ) + precondition( + pending.targetID == targetRegistry.currentMainPageTargetID, + "A current model-feed capability reply must belong to the current physical target." ) + + for domain in replayDomains { + precondition( + !synchronization.completedDomains.contains(domain), + "A model feed received duplicate replay completion in one binding generation." + ) + guard enqueueModelFeedRecord( + .replayComplete( + generation: generation, + domain: domain, + through: result.receivedSequence + ) + ) else { + // enqueueModelFeedRecord synchronously claims terminal + // ownership for overflow or a terminated consumer. + return + } + guard completeModelDomain(domain, generation: generation) else { + return + } + } } private func updateRegistryFromRootEvent( method: String, targetID: ProtocolTarget.ID?, sourceTargetID: ProtocolTarget.ID?, - paramsData: Data - ) async -> [ResolvedStyleSheetAddedEvent] { + paramsData: Data, + eventSequence: UInt64 + ) throws -> RootEventMutation { switch method { case "Target.targetCreated": - guard let params = try? TransportMessageParser.decode(TargetCreatedParams.self, from: paramsData) else { - return [] - } - return applyTargetCreated(record(for: params.targetInfo)) + let params = try TransportMessageParser.decode(TargetCreatedParams.self, from: paramsData) + return applyTargetCreated( + record(for: params.targetInfo), + eventSequence: eventSequence + ) case "Target.targetDestroyed": - guard let params = try? TransportMessageParser.decode(TargetDestroyedParams.self, from: paramsData) else { - return [] - } - await applyTargetDestroyed(params.targetId) - return [] + let params = try TransportMessageParser.decode(TargetDestroyedParams.self, from: paramsData) + return applyTargetDestroyed( + params.targetId, + eventSequence: eventSequence + ) case "Target.didCommitProvisionalTarget": - guard let params = try? TransportMessageParser.decode(TargetCommittedParams.self, from: paramsData) else { - return [] - } - return applyTargetCommitted(oldTargetID: params.oldTargetId, newTargetID: params.newTargetId) + let params = try TransportMessageParser.decode(TargetCommittedParams.self, from: paramsData) + return applyTargetCommitted( + oldTargetID: params.oldTargetId, + newTargetID: params.newTargetId, + eventSequence: eventSequence + ) case "Runtime.executionContextCreated", "Runtime.executionContextDestroyed", "Runtime.executionContextsCleared": updateRegistryFromTargetEvent( method: method, @@ -497,12 +4411,12 @@ package actor TransportSession { sourceTargetID: sourceTargetID, paramsData: paramsData ) - return [] + return RootEventMutation() case "CSS.styleSheetAdded", "CSS.styleSheetRemoved": updateCSSStyleSheetRegistry(method: method, targetID: targetID, paramsData: paramsData) - return [] + return RootEventMutation() default: - return [] + return RootEventMutation() } } @@ -557,8 +4471,29 @@ package actor TransportSession { } } - private func applyTargetCreated(_ record: ProtocolTarget.Record) -> [ResolvedStyleSheetAddedEvent] { - resolvePendingStyleSheets(for: targetRegistry.recordTargetCreated(record)) + private func applyTargetCreated( + _ record: ProtocolTarget.Record, + eventSequence: UInt64 + ) -> RootEventMutation { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID + let resolution = targetRegistry.recordTargetCreated(record) + modelTargetMutationActionForTesting?() + let bindingEffects = prepareCurrentPageBindingChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) + if targetRegistry.isCurrentPageModelTarget(record), + let target = ModelTarget(record: record) { + publishModelTargetLifecycleEvent( + .targetCreated(target), + sequence: eventSequence + ) + } + reconcileDOMBootstrapTargets() + return RootEventMutation( + pendingStyleSheetEvents: isOpen ? resolvePendingStyleSheets(for: resolution) : [], + bindingEffects: bindingEffects + ) } private func record(for targetInfo: TargetInfoPayload) -> ProtocolTarget.Record { @@ -583,34 +4518,305 @@ package actor TransportSession { ProtocolTarget.Capabilities.resolved(for: kind, domainNames: targetInfo.domains) } - private func applyTargetDestroyed(_ targetID: ProtocolTarget.ID) async { + private func applyTargetDestroyed( + _ targetID: ProtocolTarget.ID, + eventSequence: UInt64 + ) -> RootEventMutation { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID + let destroyedRecord = targetRegistry.target(for: targetID) + let destroyedTargetWasCurrent = destroyedRecord.map( + targetRegistry.isCurrentPageModelTarget + ) ?? false targetRegistry.removeTarget(targetID) + modelDocumentEpochs.removeValue(forKey: targetID) + modelTargetMutationActionForTesting?() + let capabilityWaiters = physicalTargetDidDisappear(targetID) provisionalTargetMessageStore.removeTarget(targetID) styleSheetRouting.removeTarget(targetID) runtimeContextRegistry.removeTarget(targetID) + let bindingEffects = prepareCurrentPageBindingChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) let pendingReplies = replyStore.removeTargetReplies(for: targetID) - for pending in pendingReplies { - await pending.promise.fulfill(.failure(TransportSession.Error.missingTarget(targetID))) + if destroyedTargetWasCurrent, + let destroyedRecord, + let target = ModelTarget(record: destroyedRecord) { + publishModelTargetLifecycleEvent( + .targetDestroyed(target), + sequence: eventSequence + ) } + reconcileDOMBootstrapTargets() + return RootEventMutation( + bindingEffects: bindingEffects, + physicalTargetWaiters: capabilityWaiters, + commandInvalidation: ConnectionCommandInvalidationEffects( + pendingFailures: pendingReplies.map { + ConnectionPendingReplyFailure( + pending: $0, + reason: .missingTarget(targetID) + ) + } + ) + ) } private func applyTargetCommitted( - oldTargetID: ProtocolTarget.ID?, - newTargetID: ProtocolTarget.ID - ) -> [ResolvedStyleSheetAddedEvent] { + oldTargetID: ProtocolTarget.ID, + newTargetID: ProtocolTarget.ID, + eventSequence: UInt64 + ) -> RootEventMutation { + let previousMainPageTargetID = targetRegistry.currentMainPageTargetID let mutation = targetRegistry.commitTarget(oldTargetID: oldTargetID, newTargetID: newTargetID) - if let committedOldTargetID = mutation.committedOldTargetID { - moveBufferedProvisionalTargetMessages(from: committedOldTargetID, to: newTargetID) - } + modelTargetMutationActionForTesting?() + var capabilityWaiters = PhysicalTargetDisappearanceWaiters() + var disappearedTargetID: ProtocolTarget.ID? - if mutation.shouldRetargetExternalState, - let oldTargetID = mutation.committedOldTargetID { - replyStore.retargetPendingReplies(from: oldTargetID, to: newTargetID) + if mutation.shouldRetargetExternalState { + let oldTargetID = mutation.committedOldTargetID + capabilityWaiters = physicalTargetDidDisappear(oldTargetID) + disappearedTargetID = oldTargetID + provisionalTargetMessageStore.removeTarget(oldTargetID) styleSheetRouting.retarget(from: oldTargetID, to: newTargetID) runtimeContextRegistry.retarget(oldTargetID: oldTargetID, newTargetID: newTargetID) } - return resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) + let bindingEffects = prepareCurrentPageBindingChange( + from: previousMainPageTargetID, + to: targetRegistry.currentMainPageTargetID + ) + let remainingPendingReplies = disappearedTargetID.map { + replyStore.removeTargetReplies(for: $0) + } ?? [] + if let newRecord = targetRegistry.target(for: newTargetID), + targetRegistry.isCurrentPageModelTarget(newRecord), + let newTarget = ModelTarget(record: newRecord) { + publishModelTargetLifecycleEvent( + .didCommitProvisionalTarget( + oldTargetID: WebInspectorTarget.ID(oldTargetID.rawValue), + newTarget: newTarget + ), + sequence: eventSequence + ) + } + modelDocumentEpochs.removeValue(forKey: oldTargetID) + reconcileDOMBootstrapTargets() + return RootEventMutation( + pendingStyleSheetEvents: isOpen + ? resolvePendingStyleSheets(for: mutation.resolvedFrameTarget) + : [], + bindingEffects: bindingEffects, + physicalTargetWaiters: capabilityWaiters, + commandInvalidation: ConnectionCommandInvalidationEffects( + pendingFailures: remainingPendingReplies.map { + ConnectionPendingReplyFailure( + pending: $0, + reason: .missingTarget(mutation.committedOldTargetID) + ) + } + ) + ) + } + + private func prepareCurrentPageBindingChange( + from oldTargetID: ProtocolTarget.ID?, + to newTargetID: ProtocolTarget.ID? + ) -> CurrentPageBindingChangeEffects { + guard oldTargetID != newTargetID else { + return CurrentPageBindingChangeEffects() + } + + let obsoleteBootstrapTasks = cancelModelBootstrapTasks( + generation: currentPageGeneration + ) + + if oldTargetID == nil, + newTargetID != nil, + currentPageBindingGapIsOpen { + // The old -> nil transition already opened the replacement + // generation and published its reset. Installing the replacement + // only reactivates that generation; a second generation would + // expose an empty intermediate binding as another logical page + // transition. + currentPageBindingGapIsOpen = false + let keys = capabilities.states.keys.filter { $0.route == .currentPage } + publishModelBindingChange(hasCurrentBinding: true) + guard isOpen else { + return CurrentPageBindingChangeEffects() + } + return CurrentPageBindingChangeEffects( + capabilityKeysToReconcile: keys, + releaseWaiters: [], + modelBootstrapTasksToAwait: obsoleteBootstrapTasks + ) + } + + let oldGeneration = currentPageGeneration + var commandInvalidation = invalidateModelCommands( + where: { $0.authorization.generation == oldGeneration }, + failureOverride: .staleIdentifier, + pendingFailureReason: .staleIdentifier + ) + let bindingReplies = replyStore.removePendingReplies { pending in + switch pending.purpose { + case let .direct(bindingGeneration, _): + return bindingGeneration == oldGeneration + case let .elementPickerMode(_, generation, _, _): + return generation == oldGeneration + case .modelCommand, .capability, .capabilityAuxiliary, .modelBootstrap: + return false + } + } + commandInvalidation.pendingFailures.append(contentsOf: bindingReplies.map { + ConnectionPendingReplyFailure(pending: $0, reason: .staleIdentifier) + }) + + currentPageBindingGapIsOpen = oldTargetID != nil && newTargetID == nil + currentPageGeneration = WebInspectorPage.Generation( + rawValue: currentPageGeneration.rawValue &+ 1 + ) + for key in elementPickerModes.keys where key.route == .currentPage { + guard var mode = elementPickerModes[key] else { + continue + } + mode.physical = .inactive(currentPageGeneration) + mode.activatedThrough.removeAll(keepingCapacity: true) + elementPickerModes[key] = mode + } + eventScopes.publishReset(currentPageGeneration) { sink in + sink.route == .currentPage + } + publishModelBindingChange(hasCurrentBinding: newTargetID != nil) + guard isOpen else { + return CurrentPageBindingChangeEffects( + modelBootstrapTasksToAwait: obsoleteBootstrapTasks, + commandInvalidation: commandInvalidation + ) + } + + let keys = capabilities.states.keys.filter { $0.route == .currentPage } + var releaseWaiters: [ReplyPromise] = [] + for key in keys { + guard var capability = capabilities.states[key] else { + continue + } + releaseWaiters.append(contentsOf: capability.releaseWaiters.values) + capability.releaseWaiters.removeAll() + capability.physical = .inactive(generation: currentPageGeneration) + capabilities.states[key] = capability + } + + return CurrentPageBindingChangeEffects( + capabilityKeysToReconcile: newTargetID == nil ? [] : keys, + releaseWaiters: releaseWaiters, + modelBootstrapTasksToAwait: obsoleteBootstrapTasks, + commandInvalidation: commandInvalidation + ) + } + + private func completeCurrentPageBindingChange( + _ effects: CurrentPageBindingChangeEffects + ) async { + await completeCommandInvalidationEffects(effects.commandInvalidation) + for task in effects.modelBootstrapTasksToAwait { + await task.value + } + for key in effects.capabilityKeysToReconcile { + await reconcileCapability(for: key) + if !isOpen { + break + } + } + for waiter in effects.releaseWaiters { + waiter.fulfill(.success(())) + } + } + + private func cancelModelBootstrapTasks( + generation: WebInspectorPage.Generation + ) -> [Task] { + guard let feedID = modelFeed?.id else { + return [] + } + let tasks = modelBootstrapTasks.values.filter { + $0.feedID == feedID && $0.generation == generation + }.map(\.task) + for task in tasks { + task.cancel() + } + return tasks + } + + private func completeRootEventMutation( + _ mutation: RootEventMutation + ) async { + await completeCurrentPageBindingChange(mutation.bindingEffects) + resumePhysicalTargetDisappearanceWaiters( + mutation.physicalTargetWaiters + ) + await completeCommandInvalidationEffects(mutation.commandInvalidation) + } + + private func publishModelBindingChange(hasCurrentBinding: Bool) { + guard var registration = modelFeed else { + return + } + if registration.targetSnapshotThrough != nil { + registration.targetSnapshotThrough = nil + registration.resetGeneration = currentPageGeneration + registration.synchronization = nil + registration.domBootstrap = nil + modelFeed = registration + guard enqueueModelFeedRecord(.reset(currentPageGeneration)) else { + return + } + } + if hasCurrentBinding { + precondition( + registration.resetGeneration == currentPageGeneration, + "A model target snapshot must use the generation established by its preceding reset." + ) + _ = publishModelTargetSnapshot() + } + } + + private func physicalTargetDidDisappear( + _ targetID: ProtocolTarget.ID + ) -> PhysicalTargetDisappearanceWaiters { + let route = RoutingTargetID(targetID.rawValue) + eventScopes.finishSubscribers(where: { sink in + sink.route == route + }, with: WebInspectorProxyError.pageUnavailable) + + let keys = capabilities.states.keys.filter { $0.route == route } + var waiters = PhysicalTargetDisappearanceWaiters() + for key in keys { + guard var capability = capabilities.states[key] else { + continue + } + capability.failedLeaseOwners.formUnion(capability.activationWaiters.keys) + waiters.activation.append(contentsOf: capability.activationWaiters.values) + waiters.release.append(contentsOf: capability.releaseWaiters.values) + capability.activationWaiters.removeAll() + capability.releaseWaiters.removeAll() + capability.physical = .inactive(generation: capability.physical.generation) + capabilities.states[key] = capability + capabilities.removeEmptyState(for: key) + } + + return waiters + } + + private func resumePhysicalTargetDisappearanceWaiters( + _ waiters: PhysicalTargetDisappearanceWaiters + ) { + for waiter in waiters.activation { + waiter.fulfill(.failure(WebInspectorProxyError.pageUnavailable)) + } + for waiter in waiters.release { + waiter.fulfill(.success(())) + } } private func resolvePendingStyleSheets( @@ -622,13 +4828,6 @@ package actor TransportSession { return resolvePendingStyleSheets(frameID: frameTarget.frameID, targetID: frameTarget.targetID) } - private func moveBufferedProvisionalTargetMessages( - from oldTargetID: ProtocolTarget.ID, - to newTargetID: ProtocolTarget.ID - ) { - provisionalTargetMessageStore.retargetMessages(from: oldTargetID, to: newTargetID) - } - private func dispatchCommittedProvisionalTargetMessagesIfNeeded(method: String, paramsData: Data) async { guard method == "Target.didCommitProvisionalTarget", let params = try? TransportMessageParser.decode(TargetCommittedParams.self, from: paramsData) else { @@ -638,6 +4837,9 @@ package actor TransportSession { let messages = provisionalTargetMessageStore.takeMessages(for: params.newTargetId) for message in messages { await handleTargetMessage(message, targetID: params.newTargetId) + guard isOpen else { + return + } } } @@ -777,12 +4979,19 @@ package actor TransportSession { return } for event in events { - await emit( + guard isOpen else { + return + } + let emission = emit( domain: .css, method: "CSS.styleSheetAdded", targetID: event.targetID, paramsData: event.paramsData ) + await completeEventEmissionEffects(emission) + guard isOpen else { + return + } } } @@ -804,9 +5013,27 @@ package actor TransportSession { targetID: ProtocolTarget.ID?, sourceTargetID: ProtocolTarget.ID? = nil, paramsData: Data, - destroyedCurrentMainPageTarget: Bool = false - ) async { - let eventSequence = eventSequences.recordEvent(domain: domain) + destroyedCurrentMainPageTarget: Bool = false, + reservedEventSequence: TransportEventSequenceSnapshot? = nil + ) -> EventEmissionEffects { + guard isOpen else { + return EventEmissionEffects() + } + let eventSequence: TransportEventSequenceSnapshot + if let reservedEventSequence { + precondition( + reservedEventSequence.sequence == eventSequences.current.sequence, + "A reserved root event sequence was not published before another event advanced the feed watermark." + ) + precondition( + reservedEventSequence.receivedDomainSequences[domain] + == reservedEventSequence.sequence, + "A reserved root event sequence does not own its protocol domain watermark." + ) + eventSequence = reservedEventSequence + } else { + eventSequence = eventSequences.recordEvent(domain: domain) + } let envelope = ProtocolEvent( sequence: eventSequence.sequence, domain: domain, @@ -817,48 +5044,345 @@ package actor TransportSession { paramsData: paramsData, destroyedCurrentMainPageTarget: destroyedCurrentMainPageTarget ) - for continuation in eventSubscribers.continuations(for: domain) { - continuation.yield(envelope) + // WebKit invalidates every bound node identifier before emitting + // documentUpdated. Advance the target epoch and publish its dedicated + // model boundary before this event can reach any later model delta. + let commandInvalidation = prepareDOMDocumentUpdateForModelFeed(envelope) + guard isOpen else { + return EventEmissionEffects(commandInvalidation: commandInvalidation) + } + if let eventDomain = webInspectorEventDomain(for: domain) { + var terminalViolation: String? + let targetSnapshot = snapshot() + let sinks = eventScopes.sinks(for: eventDomain).filter { sink in + guard ConnectionEventProjection.shouldDeliver( + envelope, + to: sink.route, + in: targetSnapshot + ) else { + return false + } + guard eventDomain == .inspector else { + return true + } + let key = ConnectionCapabilityKey( + route: sink.route, + targetID: sink.targetID, + domain: .inspector + ) + return elementPickerShouldDeliver( + eventSequence.sequence, + to: .eventScope(sink.id), + key: key + ) + } + var projectedEvents: [ConnectionCapabilityKey: WebInspectorProxyEvent] = [:] + do { + if sinks.isEmpty { + let targetID = WebInspectorTarget.ID.currentPage + _ = try LiveProxyEventDecoder.proxyEvent( + from: envelope, + targetID: targetID, + lifecycleTarget: ConnectionEventProjection.lifecycleTarget( + for: envelope, + route: .currentPage, + targetID: targetID, + in: targetSnapshot + ) + ) + } + + for sink in sinks { + let key = ConnectionCapabilityKey( + route: sink.route, + targetID: sink.targetID, + domain: sink.domain + ) + let projectedEvent: WebInspectorProxyEvent + if let cachedEvent = projectedEvents[key] { + projectedEvent = cachedEvent + } else { + let decodedEvent = try LiveProxyEventDecoder.proxyEvent( + from: envelope, + targetID: sink.targetID, + lifecycleTarget: ConnectionEventProjection.lifecycleTarget( + for: envelope, + route: sink.route, + targetID: sink.targetID, + in: targetSnapshot + ) + ) + projectedEvent = ConnectionEventProjection.projectedEvent( + decodedEvent, + from: envelope, + route: sink.route, + in: targetSnapshot + ) + projectedEvents[key] = projectedEvent + } + + let result = sink.yieldEvent(generation(for: sink.route), projectedEvent) + switch result { + case .mismatchedEvent: + terminalViolation = "Decoded \(method) as an event outside \(eventDomain.rawValue)." + case .enqueued, .dropped, .terminated: + eventScopes.handleDelivery( + result, + id: sink.id, + capacity: eventScopes.entries[sink.id]?.capacity + ) + } + if terminalViolation != nil { + break + } + } + } catch { + terminalViolation = "Failed to decode \(method): \(error)" + } + if let terminalViolation { + handoffTermination(.protocolViolation(terminalViolation)) + return EventEmissionEffects(commandInvalidation: commandInvalidation) + } + } + do { + try publishConfiguredModelEvent( + envelope, + in: snapshot() + ) + } catch { + handoffTermination( + .protocolViolation("Failed to decode \(method): \(error)") + ) + return EventEmissionEffects(commandInvalidation: commandInvalidation) + } + startNextDOMBootstrapIfNeeded() + guard isOpen else { + return EventEmissionEffects(commandInvalidation: commandInvalidation) + } + return EventEmissionEffects( + mainPageTargetNotification: prepareMainPageTargetNotificationIfNeeded( + receivedSequence: eventSequence.sequence + ), + commandInvalidation: commandInvalidation + ) + } + + private func completeEventEmissionEffects( + _ effects: EventEmissionEffects + ) async { + await completeCommandInvalidationEffects(effects.commandInvalidation) + completeMainPageTargetNotification(effects.mainPageTargetNotification) + } + + private func publishConfiguredModelEvent( + _ event: ProtocolEvent, + in targetSnapshot: TransportSession.Snapshot + ) throws { + guard let registration = modelFeed, + let targetSnapshotThrough = registration.targetSnapshotThrough, + event.sequence > targetSnapshotThrough, + targetRegistry.currentMainPageTargetID != nil else { + return + } + guard event.domain != .target else { + // Target lifecycle records are projected synchronously with their + // registry mutation, before that path can suspend. + return + } + guard event.domain != .dom || event.method != "DOM.documentUpdated" else { + // The target + epoch record is the model feed's sole document + // invalidation boundary. Public structured scopes still receive + // their independently projected DOM event where applicable. + return + } + + let configuredDomain = modelDomain(for: event.domain) + let elementPickerOwner = ConnectionCapabilityLeaseOwner.modelElementPicker( + registration.id + ) + let elementPickerKey = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: .inspector + ) + let isElementPickerEvent = event.domain == .inspector + && registration.configuredDomains.contains(.dom) + && registration.elementPickerLease != nil + && elementPickerShouldDeliver( + event.sequence, + to: elementPickerOwner, + key: elementPickerKey + ) + guard event.domain == .page + || configuredDomain.map(registration.configuredDomains.contains) == true + || isElementPickerEvent else { + return + } + guard ConnectionEventProjection.shouldDeliver( + event, + to: .currentPage, + in: targetSnapshot + ) else { + return + } + + let physicalTargetID = event.targetID ?? targetRegistry.currentMainPageTargetID + guard let physicalTargetID, + let physicalRecord = targetRegistry.target(for: physicalTargetID), + targetRegistry.isCurrentPageModelTarget(physicalRecord), + let target = ModelTarget(record: physicalRecord) else { + return + } + + let semanticTargetID = WebInspectorTarget.ID.currentPage + let decodedEvent = try LiveProxyEventDecoder.proxyEvent( + from: event, + targetID: semanticTargetID, + lifecycleTarget: ConnectionEventProjection.lifecycleTarget( + for: event, + route: .currentPage, + targetID: semanticTargetID, + in: targetSnapshot + ) + ) + let projectedEvent = ConnectionEventProjection.projectedEvent( + decodedEvent, + from: event, + route: .currentPage, + in: targetSnapshot + ) + + let payload: ModelProtocolEvent? + switch projectedEvent { + case let .targetLifecycle(lifecycle): + switch lifecycle { + case let .frameNavigated(frame): + payload = .target(.frameNavigated(frame)) + case let .frameDetached(frameID): + payload = .target(.frameDetached(frameID: frameID)) + case .didCommitProvisionalTarget, .targetDestroyed, .unknown: + payload = nil + } + case let .dom(value): + guard configuredDomain == .dom else { + throw TransportSession.Error.malformedMessage + } + payload = .dom(target: target, event: value) + case let .css(value): + guard configuredDomain == .css else { + throw TransportSession.Error.malformedMessage + } + payload = .css(target: target, event: value) + case let .network(value): + guard configuredDomain == .network else { + throw TransportSession.Error.malformedMessage + } + payload = .network(target: target, event: value) + case let .console(value): + guard configuredDomain == .console else { + throw TransportSession.Error.malformedMessage + } + payload = .console(target: target, event: value.event) + case let .runtime(value): + guard configuredDomain == .runtime else { + throw TransportSession.Error.malformedMessage + } + payload = .runtime(target: target, event: value) + case let .inspector(value): + guard isElementPickerEvent else { + return + } + payload = .inspector(target: target, event: value) + } + + guard let payload else { + return + } + _ = enqueueModelFeedRecord( + .event( + generation: currentPageGeneration, + sequence: event.sequence, + payload: payload + ) + ) + } + + private func modelDomain(for domain: ProtocolDomain) -> ModelDomain? { + switch domain { + case .dom: + .dom + case .css: + .css + case .network: + .network + case .console: + .console + case .runtime: + .runtime + case .target, .page, .inspector, .storage, .other: + nil } - for continuation in eventSubscribers.orderedContinuations { - continuation.yield(envelope) + } + + private func webInspectorEventDomain(for domain: ProtocolDomain) -> WebInspectorProxyEventDomain? { + switch domain { + case .target: + .target + case .runtime: + .runtime + case .dom: + .dom + case .css: + .css + case .network: + .network + case .console: + .console + case .page: + .page + case .inspector: + .inspector + case .storage, .other: + nil } - await notifyMainPageTargetWaitersIfNeeded(receivedSequence: eventSequence.sequence) } - private func notifyMainPageTargetWaitersIfNeeded(receivedSequence: UInt64) async { + private func prepareMainPageTargetNotificationIfNeeded( + receivedSequence: UInt64 + ) -> MainPageTargetNotification? { guard let currentMainPageTargetID = targetRegistry.currentMainPageTargetID, !mainPageTargetWaiterStore.isEmpty else { - return + return nil } let waiters = mainPageTargetWaiterStore.removeAll() let result = TransportSession.MainPageTarget( targetID: currentMainPageTargetID, receivedSequence: receivedSequence ) - for waiter in waiters { - await waiter.fulfill(.success(result)) - } - } - - private func failMainPageTargetWaiter(_ waiterID: UInt64, error: any Swift.Error) async { - let waiter = mainPageTargetWaiterStore.remove(id: waiterID) - await waiter?.fulfill(.failure(error)) + return MainPageTargetNotification(waiters: waiters, result: result) } - private func removeSubscriber(_ subscriberID: UInt64, domain: ProtocolDomain) { - eventSubscribers.remove(subscriberID, domain: domain) + private func completeMainPageTargetNotification( + _ notification: MainPageTargetNotification? + ) { + guard let notification else { + return + } + for waiter in notification.waiters { + waiter.fulfill(.success(notification.result)) + } } - private func removeOrderedSubscriber(_ subscriberID: UInt64) { - eventSubscribers.removeOrdered(subscriberID) + private func failMainPageTargetWaiter(_ waiterID: UInt64, error: any Swift.Error) { + let waiter = mainPageTargetWaiterStore.remove(id: waiterID) + waiter?.fulfill(.failure(error)) } private func removePendingReply(_ key: TransportSession.PendingKey) { replyStore.removePendingReply(key) } - private func failPendingReply(_ key: TransportSession.PendingKey, error: any Swift.Error) async { + private func failPendingReply(_ key: TransportSession.PendingKey, error: any Swift.Error) { let pending: TransportSession.PendingReply? switch key { case let .root(commandID): @@ -867,10 +5391,10 @@ package actor TransportSession { pending = replyStore.removeTargetReply(for: targetReplyKey) ?? replyStore.removeRetargetedReply(commandID: targetReplyKey.commandID) } - await pending?.promise.fulfill(.failure(error)) + pending?.promise.fulfill(.failure(error)) } - private func failPendingReplyFromTimeout(_ key: TransportSession.PendingKey, error: any Swift.Error) async { + private func failPendingReplyFromTimeout(_ key: TransportSession.PendingKey, error: any Swift.Error) { let pending: TransportSession.PendingReply? switch key { case let .root(commandID): @@ -878,7 +5402,7 @@ package actor TransportSession { case let .target(targetReplyKey): pending = replyStore.removeTargetReplyForTimeout(targetReplyKey) } - await pending?.promise.fulfill(.failure(error)) + pending?.promise.fulfill(.failure(error)) } private func markTargetReplyAsBufferedIfNeeded( @@ -890,8 +5414,247 @@ package actor TransportSession { } replyStore.markTargetReplyAsBufferedIfNeeded(commandID: commandID, targetID: targetID) } + + private var terminalTransportError: TransportSession.Error { + switch claimedTerminalCause { + case .explicitClose: + .transportClosed + case let .fatal(message): + .transportFailure(message) + case let .protocolViolation(message): + .transportFailure(message) + case let .modelFeedFailure(error): + .transportFailure(String(describing: error)) + } + } + + private var terminalScopeError: WebInspectorProxyError { + switch claimedTerminalCause { + case .explicitClose: + WebInspectorProxyError.closed + case let .fatal(message): + WebInspectorProxyError.transportFailure(message) + case let .protocolViolation(message): + WebInspectorProxyError.protocolViolation(message) + case let .modelFeedFailure(error): + WebInspectorProxyError.transportFailure(String(describing: error)) + } + } + + private func terminate(_ proposedCause: TerminalCause) async { + let cause = terminalClaim.claim(proposedCause).cause + switch state { + case .open: + state = .closing + case .closing: + precondition(claimedTerminalCause == cause, "ConnectionCore terminal claims diverged.") + try? await waitUntilClosed() + return + case .closed: + precondition(claimedTerminalCause == cause, "ConnectionCore terminal claims diverged.") + return + } + + await finishClaimedTermination(cause) + } + + private func handoffTermination(_ proposedCause: TerminalCause) { + let cause = terminalClaim.claim(proposedCause).cause + beginClaimedTerminationHandoff(cause) + } + + private func beginClaimedTerminationHandoff(_ cause: TerminalCause) { + precondition(terminalClaim.current == cause, "ConnectionCore terminal claims diverged.") + switch state { + case .open: + break + case .closing, .closed: + precondition(claimedTerminalCause == cause, "ConnectionCore terminal claims diverged.") + return + } + state = .closing + let operation = prepareClaimedTermination(cause) + precondition(terminalTask == nil, "ConnectionCore already owns a terminal task.") + terminalTask = Task { [weak self, operation] in + await operation.run() + await self?.finishClaimedTerminalState(cause) + } + } + + private func finishClaimedTermination(_ cause: TerminalCause) async { + let operation = prepareClaimedTermination(cause) + await operation.run() + finishClaimedTerminalState(cause) + } + + private func prepareClaimedTermination(_ cause: TerminalCause) -> TerminalOperation { + precondition(terminalClaim.current == cause, "ConnectionCore terminal claims diverged.") + let transportError = terminalTransportError + let scopeError: WebInspectorProxyError? = cause == .explicitClose ? nil : terminalScopeError + if let scopeError { + eventScopes.finishSubscribers(with: scopeError) + switch cause { + case let .modelFeedFailure(error): + // The package model consumer needs the exact terminal feed + // category. Direct/public consumers still observe the mapped + // connection-level scope error above. + modelFeed?.mailbox.poison(throwing: error) + case .explicitClose, .fatal, .protocolViolation: + modelFeed?.mailbox.poison(throwing: scopeError) + } + } + + var activationWaiters: [ReplyPromise] = [] + var releaseWaiters: [ReplyPromise] = [] + for key in capabilities.states.keys { + guard var capability = capabilities.states[key] else { + continue + } + activationWaiters.append(contentsOf: capability.activationWaiters.values) + releaseWaiters.append(contentsOf: capability.releaseWaiters.values) + capability.activationWaiters.removeAll() + capability.releaseWaiters.removeAll() + capability.physical = .inactive(generation: capability.physical.generation) + capabilities.states[key] = capability + } + + let pendingReplyRecords = replyStore.pendingReplyRecords + for (key, pending) in pendingReplyRecords { + validateReplyOwnership(pending, key: key) + } + let pendingReplies = Array(pendingReplyRecords.values) + var runningModelCommandTasks: [Task] = [] + for operationID in modelCommandTasks.keys.sorted() { + guard var commandTask = modelCommandTasks[operationID] else { + continue + } + commandTask.control.failFromOwner(.terminal(transportError)) + let readinessSignal = commandTask.readinessSignal + commandTask.readinessSignal = nil + modelCommandTasks[operationID] = commandTask + readinessSignal?.fulfill(.failure(transportError)) + if let task = commandTask.control.task { + runningModelCommandTasks.append(task) + } + } + resumeModelCommandOwnerCountWaitersIfNeeded() + let runningCapabilityTasks = capabilityTasks.values.map(\.task) + capabilityTasks.removeAll() + for task in runningCapabilityTasks { + task.cancel() + } + let runningModelBootstrapTasks = modelBootstrapTasks.values.map(\.task) + modelBootstrapTasks.removeAll() + for task in runningModelBootstrapTasks { + task.cancel() + } + let eventScopeWaiters = eventScopeRegistrationWaiters + eventScopeRegistrationWaiters.removeAll() + for waiter in eventScopeWaiters { + waiter.continuation.resume() + } + let mainPageTargetWaiters = mainPageTargetWaiterStore.removeAll() + replyStore.removeAll() + provisionalTargetMessageStore.removeAll() + inboundMessageQueue = TransportInboundMessageQueue() + if cause != .explicitClose { + } + + return TerminalOperation( + transportError: transportError, + scopeError: scopeError, + pendingReplies: pendingReplies, + mainPageTargetWaiters: mainPageTargetWaiters, + activationWaiters: activationWaiters, + releaseWaiters: releaseWaiters, + capabilityTasks: runningCapabilityTasks, + modelBootstrapTasks: runningModelBootstrapTasks, + modelCommandTasks: runningModelCommandTasks, + closeAction: closeAction + ) + } + + private func finishClaimedTerminalState(_ cause: TerminalCause) { + precondition(terminalClaim.current == cause, "ConnectionCore terminal claims diverged.") + // A close action is allowed to suspend. The first terminal cause owns + // the transition even if another close/fatal request arrives while it + // is running. + guard case .closing = state, + claimedTerminalCause == cause else { + preconditionFailure("ConnectionCore terminal state changed outside terminate(_:).") + } + state = .closed + if cause == .explicitClose { + eventScopes.finishSubscribers(with: nil) + modelFeed?.mailbox.finish() + } + modelFeed = nil + resumeCloseWaiters(with: terminalResult(for: cause)) + terminalTask = nil + } + + private func terminalResult(for cause: TerminalCause) -> Result { + switch cause { + case .explicitClose: + .success(()) + case let .fatal(message): + .failure(WebInspectorProxyError.disconnected(message)) + case let .protocolViolation(message): + .failure(WebInspectorProxyError.protocolViolation(message)) + case let .modelFeedFailure(error): + .failure(WebInspectorProxyError.transportFailure(String(describing: error))) + } + } + + private func registerCloseWaiter( + id: UInt64, + continuation: CheckedContinuation + ) { + if case .closed = state { + continuation.resume(with: terminalResult(for: claimedTerminalCause)) + return + } + guard cancelledCloseWaiterIDs.remove(id) == nil else { + continuation.resume(throwing: CancellationError()) + return + } + closeWaiters[id] = continuation + resumeCloseWaiterRegistrationWaiters() + } + + private func cancelCloseWaiter(_ id: UInt64) { + guard let continuation = closeWaiters.removeValue(forKey: id) else { + if case .closed = state { + return + } + cancelledCloseWaiterIDs.insert(id) + return + } + continuation.resume(throwing: CancellationError()) + } + + private func resumeCloseWaiters(with result: Result) { + let waiters = closeWaiters.values + closeWaiters.removeAll() + cancelledCloseWaiterIDs.removeAll() + for waiter in waiters { + waiter.resume(with: result) + } + } + + private func resumeCloseWaiterRegistrationWaiters() { + let waiters = closeWaiterRegistrationWaiters + closeWaiterRegistrationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } } +/// Temporary package-only spelling retained while downstream package targets +/// migrate in later phases. It is a typealias, not a second lifecycle owner. +package typealias TransportSession = ConnectionCore + private struct TargetDispatchParams: Decodable { var targetId: ProtocolTarget.ID var message: String @@ -917,7 +5680,7 @@ private struct TargetDestroyedParams: Decodable { } private struct TargetCommittedParams: Decodable { - var oldTargetId: ProtocolTarget.ID? + var oldTargetId: ProtocolTarget.ID var newTargetId: ProtocolTarget.ID } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift b/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift index 6f51a235..8ccb7413 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportTargetRegistry.swift @@ -101,13 +101,10 @@ struct TransportTargetRegistry: Sendable { } mutating func commitTarget( - oldTargetID: ProtocolTarget.ID?, + oldTargetID: ProtocolTarget.ID, newTargetID: ProtocolTarget.ID ) -> TransportTargetCommitMutation { - let committedOldTargetID = oldTargetID ?? inferredOldTargetIDForOldlessCommit(newTargetID: newTargetID) - - if let oldTargetID = committedOldTargetID, - oldTargetID == currentMainPageTargetID, + if oldTargetID == currentMainPageTargetID, let existingNewRecord = targetsByID[newTargetID], !existingNewRecord.isTopLevelPage { var committedSubframeRecord = existingNewRecord @@ -117,16 +114,16 @@ struct TransportTargetRegistry: Sendable { frameTargetIDsByFrameID[frameID] = newTargetID } return TransportTargetCommitMutation( - committedOldTargetID: committedOldTargetID, + committedOldTargetID: oldTargetID, shouldRetargetExternalState: false, resolvedFrameTarget: committedFrameTargetResolution(for: committedSubframeRecord) ) } - let oldRecord = committedOldTargetID.flatMap { targetsByID.removeValue(forKey: $0) } + let oldRecord = targetsByID.removeValue(forKey: oldTargetID) guard oldRecord != nil || targetsByID[newTargetID] != nil else { return TransportTargetCommitMutation( - committedOldTargetID: committedOldTargetID, + committedOldTargetID: oldTargetID, shouldRetargetExternalState: false, resolvedFrameTarget: nil ) @@ -139,15 +136,12 @@ struct TransportTargetRegistry: Sendable { newRecord.isProvisional = false targetsByID[newTargetID] = newRecord - if let oldTargetID = committedOldTargetID { - frameTargetIDsByFrameID = frameTargetIDsByFrameID.filter { $0.value != oldTargetID } - } + frameTargetIDsByFrameID = frameTargetIDsByFrameID.filter { $0.value != oldTargetID } if let frameID = newRecord.frameID { frameTargetIDsByFrameID[frameID] = newTargetID } - if let oldTargetID = committedOldTargetID, - currentMainPageTargetID == oldTargetID, + if currentMainPageTargetID == oldTargetID, newRecord.isTopLevelPage { currentMainPageTargetID = newTargetID } @@ -158,36 +152,87 @@ struct TransportTargetRegistry: Sendable { } return TransportTargetCommitMutation( - committedOldTargetID: committedOldTargetID, - shouldRetargetExternalState: committedOldTargetID != nil, + committedOldTargetID: oldTargetID, + shouldRetargetExternalState: true, resolvedFrameTarget: committedFrameTargetResolution(for: newRecord) ) } - private func inferredOldTargetIDForOldlessCommit( - newTargetID: ProtocolTarget.ID - ) -> ProtocolTarget.ID? { - if let newRecord = targetsByID[newTargetID], - newRecord.isProvisional, - newRecord.isTopLevelPage, - let currentMainPageTargetID, - currentMainPageTargetID != newTargetID { - if let frameID = newRecord.frameID, - let currentMainFrameID, - frameID != currentMainFrameID { + func modelTargetSnapshot() -> ModelTargetSnapshot? { + guard let currentMainPageTargetID, + let currentPageRecord = targetsByID[currentMainPageTargetID], + !currentPageRecord.isProvisional, + let currentPageTarget = ModelTarget(record: currentPageRecord) else { + return nil + } + + let frames = targetsByID.values.compactMap { record -> ( + depth: Int, + target: ModelTarget + )? in + guard let depth = currentPageFrameDepth(for: record), + let target = ModelTarget(record: record) else { return nil } - return currentMainPageTargetID + return (depth, target) + }.sorted { lhs, rhs in + if lhs.depth != rhs.depth { + return lhs.depth < rhs.depth + } + return lhs.target.id.rawValue < rhs.target.id.rawValue + }.map(\.target) + + return ModelTargetSnapshot( + currentPageID: currentPageTarget.id, + targets: [currentPageTarget] + frames + ) + } + + func isCurrentPageModelTarget(_ record: ProtocolTarget.Record) -> Bool { + guard !record.isProvisional else { + return false + } + if record.id == currentMainPageTargetID { + return true } + return currentPageFrameDepth(for: record) != nil + } - guard targetsByID[newTargetID] == nil else { + private func currentPageFrameDepth( + for record: ProtocolTarget.Record + ) -> Int? { + guard record.kind == .frame, + !record.isProvisional, + let frameID = record.frameID, + let currentMainPageTargetID, + let mainFrameID = targetsByID[currentMainPageTargetID]?.frameID, + frameID != mainFrameID else { return nil } + guard var parentFrameID = record.parentFrameID else { + // The registry has already classified this physical record as a + // frame. With no parent ancestry signal, its distinct frame ID is + // the only current-page membership fact owned by this registry. + return 1 + } - let provisionalTargetIDs = targetsByID - .filter { $0.value.isProvisional } - .map(\.key) - return provisionalTargetIDs.count == 1 ? provisionalTargetIDs[0] : nil + var depth = 1 + var visited = Set() + while visited.insert(parentFrameID).inserted { + if parentFrameID == mainFrameID { + return depth + } + guard let parentTargetID = frameTargetIDsByFrameID[parentFrameID], + let parentRecord = targetsByID[parentTargetID], + parentRecord.kind == .frame, + !parentRecord.isProvisional, + let nextParentFrameID = parentRecord.parentFrameID else { + return nil + } + depth += 1 + parentFrameID = nextParentFrameID + } + preconditionFailure("The current-page frame target graph contains a cycle.") } private func committedFrameTargetResolution( @@ -213,7 +258,7 @@ struct TransportFrameTargetResolution: Sendable { } struct TransportTargetCommitMutation: Sendable { - var committedOldTargetID: ProtocolTarget.ID? + var committedOldTargetID: ProtocolTarget.ID var shouldRetargetExternalState: Bool var resolvedFrameTarget: TransportFrameTargetResolution? } diff --git a/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift b/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift index 5eee6d0a..6b99f6b8 100644 --- a/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift +++ b/Sources/WebInspectorProxyKit/Transport/TransportTypes.swift @@ -107,17 +107,20 @@ package struct ProtocolCommand: Equatable, Sendable { package var method: String package var routing: Routing package var parametersData: Data + package var authority: WebInspectorCommandAuthority package init( domain: ProtocolDomain, method: String, routing: Routing, - parametersData: Data = Data("{}".utf8) + parametersData: Data = Data("{}".utf8), + authority: WebInspectorCommandAuthority = .direct ) { self.domain = domain self.method = method self.routing = routing self.parametersData = parametersData + self.authority = authority } } @@ -220,5 +223,6 @@ package extension TransportSession { case replyTimeout(method: String, targetID: ProtocolTarget.ID?) case remoteError(method: String, targetID: ProtocolTarget.ID?, message: String) case transportClosed + case transportFailure(String) } } diff --git a/Sources/WebInspectorProxyKit/WebInspectorCommandAuthority.swift b/Sources/WebInspectorProxyKit/WebInspectorCommandAuthority.swift new file mode 100644 index 00000000..f0fb376f --- /dev/null +++ b/Sources/WebInspectorProxyKit/WebInspectorCommandAuthority.swift @@ -0,0 +1,63 @@ +import Foundation +import Synchronization + +package enum WebInspectorCommandAuthority: Equatable, Sendable { + case direct + case modelFeed(ConnectionModelCommandAuthorization) +} + +package struct ConnectionModelCommandAuthorization: Equatable, Sendable { + package struct Document: Equatable, Sendable { + package let targetID: WebInspectorTarget.ID + package let epoch: ModelDocumentEpoch + + package init( + targetID: WebInspectorTarget.ID, + epoch: ModelDocumentEpoch + ) { + self.targetID = targetID + self.epoch = epoch + } + } + + package let feedID: ConnectionModelFeedID + package let generation: WebInspectorPage.Generation + package let document: Document? + + package init( + feedID: ConnectionModelFeedID, + generation: WebInspectorPage.Generation, + document: Document? = nil + ) { + self.feedID = feedID + self.generation = generation + self.document = document + } +} + +package enum ConnectionModelCommandError: Error, Equatable, Sendable { + case notActive + case domainNotConfigured(WebInspectorProxyDomain) + case internalCommand(domain: WebInspectorProxyDomain, method: String) + case documentAuthorizationRequired(WebInspectorProxyDomain) +} + +/// Lets value-typed child handles find their connection without becoming a +/// second owner of its asynchronous close lifecycle. +package final class WebInspectorProxyReference: Sendable { + private struct State { + weak var proxy: WebInspectorProxy? + } + + private let state: Mutex + + package init(_ proxy: WebInspectorProxy) { + state = Mutex(State(proxy: proxy)) + } + + package func resolve() -> WebInspectorProxy? { + state.withLock { state in + state.proxy + } + } +} diff --git a/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift new file mode 100644 index 00000000..3a29a8c9 --- /dev/null +++ b/Sources/WebInspectorProxyKit/WebInspectorDomainHandle.swift @@ -0,0 +1,147 @@ +import Foundation + +package struct DomainEndpoint: Sendable { + package let proxyReference: WebInspectorProxyReference + package let targetID: WebInspectorTarget.ID + package let route: RoutingTargetID + package let authority: WebInspectorCommandAuthority + + package init( + proxy: WebInspectorProxy, + targetID: WebInspectorTarget.ID, + route: RoutingTargetID, + authority: WebInspectorCommandAuthority = .direct + ) { + self.init( + proxyReference: WebInspectorProxyReference(proxy), + targetID: targetID, + route: route, + authority: authority + ) + } + + package init( + proxyReference: WebInspectorProxyReference, + targetID: WebInspectorTarget.ID, + route: RoutingTargetID, + authority: WebInspectorCommandAuthority + ) { + self.proxyReference = proxyReference + self.targetID = targetID + self.route = route + self.authority = authority + } + + package func dispatch( + domain: WebInspectorProxyDomain, + method: String, + payload: Payload, + returning resultType: Result.Type = Result.self + ) async throws -> Result { + _ = resultType + guard let proxy = proxyReference.resolve() else { + throw WebInspectorProxyError.closed + } + return try await proxy.dispatchCommand( + targetID: targetID, + route: route, + domain: domain, + method: method, + payload: payload, + authority: authority + ) + } + + package func withEvents( + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + isolation: isolated (any Actor)? = #isolation, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element?, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + let backend: any WebInspectorProxyBackend = try { + guard let proxy = proxyReference.resolve() else { + throw WebInspectorProxyError.closed + } + guard let backend = proxy.structuredEventBackend else { + throw unimplementedCommand(domain: domain.rawValue, method: "withEvents") + } + return backend + }() + return try await withWebInspectorEventScope( + backend: backend, + targetID: targetID, + route: route, + domain: domain, + buffering: buffering, + isolation: isolation, + extract: extract, + operation + ) + } + +} + +/// Package-owned contract shared by the closed set of Web Inspector domain +/// handles. The protocol is not public because ProxyKit does not support +/// consumer-defined protocol domains. +package protocol WebInspectorDomainHandle: Sendable { + static var commandDomain: WebInspectorProxyDomain { get } + + var endpoint: DomainEndpoint { get } +} + +package extension WebInspectorDomainHandle { + func dispatch( + method: String, + payload: Payload, + returning resultType: Result.Type = Result.self + ) async throws -> Result { + try await endpoint.dispatch( + domain: Self.commandDomain, + method: method, + payload: payload, + returning: resultType + ) + } + + func dispatchVoid( + method: String, + payload: Payload + ) async throws { + let _: Void = try await dispatch( + method: method, + payload: payload, + returning: Void.self + ) + } +} + +/// Package-owned contract for domain handles that vend structured events. +package protocol WebInspectorEventDomainHandle: WebInspectorDomainHandle { + associatedtype Event: Sendable + + static var eventDomain: WebInspectorProxyEventDomain { get } + + static func extractEvent(_ event: WebInspectorProxyEvent) -> Event? +} + +package extension WebInspectorEventDomainHandle { + func _withEvents( + buffering: WebInspectorEventBufferingPolicy, + isolation: isolated (any Actor)? = #isolation, + _ operation: ( + AsyncThrowingStream, any Error> + ) async throws -> Output + ) async throws -> Output { + try await endpoint.withEvents( + domain: Self.eventDomain, + buffering: buffering, + isolation: isolation, + extract: Self.extractEvent, + operation + ) + } +} diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift index f4233b13..bd5bb0a6 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxy.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxy.swift @@ -8,32 +8,27 @@ private let logger = Logger(subsystem: "WebInspectorKit", category: "WebInspecto private struct ProtocolCommandTarget: Sendable { var targetID: WebInspectorTarget.ID var route: RoutingTargetID - var resultTargetScopeRawValue: String? init( targetID: WebInspectorTarget.ID, - route: RoutingTargetID, - resultTargetScopeRawValue: String? = nil + route: RoutingTargetID ) { self.targetID = targetID self.route = route - self.resultTargetScopeRawValue = resultTargetScopeRawValue } } /// An attached Web Inspector protocol connection for a `WKWebView`. /// -/// `WebInspectorProxy` owns the private WebKit inspector attachment, tracks the -/// current page target, and routes typed domain commands through -/// ``WebInspectorTarget`` values. +/// `WebInspectorProxy` is a handle to the private WebKit inspector connection. +/// Its connection core owns the current physical page binding and routes typed +/// domain commands through its stable ``page`` handle. /// /// Example: /// /// ```swift /// let proxy = try await WebInspectorProxy(attachingTo: webView) -/// let page = try await proxy.waitForCurrentPage() -/// -/// try await page.runtime.enable() +/// let page = proxy.page /// let evaluation = try await page.runtime.evaluate("document.title") /// print(evaluation.object.description ?? "") /// @@ -61,34 +56,28 @@ public actor WebInspectorProxy { private let configuration: Configuration private let backend: (any WebInspectorProxyBackend)? - private let transport: TransportSession? - private let closeConnection: (@Sendable () async -> Void)? - private var pageTarget: WebInspectorTarget? - private var nextTargetOrdinal: UInt64 - private var nextCloseWaiterID: UInt64 - private var closeWaiters: [UInt64: CheckedContinuation] - private var closeWaiterRegistrationWaiters: [CheckedContinuation] - private var cancelledCloseWaiterIDs: Set - private var closeState: CloseState - - private enum CloseState { - case open - case closing - case closed + private let core: ConnectionCore + + package nonisolated var structuredEventBackend: (any WebInspectorProxyBackend)? { + backend + } + + /// The stable logical page inspected by this connection. + public nonisolated var page: WebInspectorPage { + WebInspectorPage(proxy: self) } /// Attaches a Web Inspector protocol connection to a web view. /// - /// Attach from the main actor because `WKWebView` is a UI object. Use - /// ``waitForCurrentPage()`` before dispatching page-scoped commands. + /// Attach from the main actor because `WKWebView` is a UI object. @MainActor public init( attachingTo webView: WKWebView, configuration: Configuration = .init() ) async throws { - let nativeConnection: NativeInspectorConnection + let nativeCore: ConnectionCore do { - nativeConnection = try await NativeInspectorConnectionFactory.attach( + nativeCore = try await NativeConnectionCoreFactory.attach( to: webView, responseTimeout: configuration.responseTimeout, fatalFailureHandler: { message in @@ -100,21 +89,11 @@ public actor WebInspectorProxy { } self.configuration = configuration - backend = LiveWebInspectorProxyBackend(transport: nativeConnection.transport) - transport = nativeConnection.transport - closeConnection = { - await nativeConnection.close() - } - pageTarget = nil - nextTargetOrdinal = 0 - nextCloseWaiterID = 0 - closeWaiters = [:] - closeWaiterRegistrationWaiters = [] - cancelledCloseWaiterIDs = [] - closeState = .open + backend = LiveWebInspectorProxyBackend(transport: nativeCore) + core = nativeCore do { - try await bootstrapCurrentPage(from: nativeConnection.transport) + try await bootstrapCurrentPage(from: nativeCore) } catch { await close() throw Self.mapNativeAttachError(error) @@ -122,21 +101,16 @@ public actor WebInspectorProxy { } package init( - configuration: Configuration = .init(), - backend: (any WebInspectorProxyBackend)? = nil, + localStateOnly: Void, closeConnection: (@Sendable () async -> Void)? = nil ) { - self.configuration = configuration - self.backend = backend - transport = nil - self.closeConnection = closeConnection - pageTarget = nil - nextTargetOrdinal = 0 - nextCloseWaiterID = 0 - closeWaiters = [:] - closeWaiterRegistrationWaiters = [] - cancelledCloseWaiterIDs = [] - closeState = .open + configuration = .init() + backend = nil + core = ConnectionCore( + backend: UnavailableTransportBackend(), + responseTimeout: nil, + closeAction: closeConnection + ) } package init( @@ -145,18 +119,12 @@ public actor WebInspectorProxy { closeConnection: (@Sendable () async -> Void)? = nil ) async throws { self.configuration = configuration - self.transport = transport + core = transport backend = LiveWebInspectorProxyBackend(transport: transport) - self.closeConnection = closeConnection ?? { - await transport.detach() + + if let closeConnection { + await transport.replaceCloseActionForTesting(closeConnection) } - pageTarget = nil - nextTargetOrdinal = 0 - nextCloseWaiterID = 0 - closeWaiters = [:] - closeWaiterRegistrationWaiters = [] - cancelledCloseWaiterIDs = [] - closeState = .open do { try await bootstrapCurrentPage(from: transport) @@ -166,42 +134,31 @@ public actor WebInspectorProxy { } } - /// The currently known page target, if bootstrap has completed. - public var currentPage: WebInspectorTarget? { - pageTarget + package var currentPage: WebInspectorTarget? { + get async { + guard let record = await core.currentMainPageRecord() else { + return nil + } + return try? currentPageTarget(from: record) + } } package var currentPageBindingID: String? { - pageTarget?.pageBindingID - } - - /// A Boolean value indicating whether the proxy has an open page target - /// that can receive reload commands. - public var canReload: Bool { - pageTarget != nil && closeState == .open + get async { + await core.currentMainPageRecord()?.id.rawValue + } } - /// Waits for and returns the current page target. - /// - /// The proxy refreshes its current-page target from the transport when - /// possible. The method throws if the proxy is closed, detached, or no page - /// target can be discovered before the bootstrap timeout. - public func waitForCurrentPage() async throws -> WebInspectorTarget { - try ensureOpenForCurrentPageAccess() - if let transport { - do { - try await refreshCurrentPage(from: transport, timeout: configuration.bootstrapTimeout) - } catch { - throw Self.mapBootstrapTargetError(error) - } - if let pageTarget { - return pageTarget - } - } - if let pageTarget { - return pageTarget + package func waitForCurrentPage() async throws -> WebInspectorTarget { + try await ensureOpenForCurrentPageAccess() + do { + return try await currentPageTarget( + from: core, + timeout: configuration.bootstrapTimeout + ) + } catch { + throw Self.mapBootstrapTargetError(error) } - throw WebInspectorProxyError.disconnected("WebInspectorProxyKit shell has no current page target.") } /// Waits for a usable current page target after the previous one was @@ -211,58 +168,36 @@ public actor WebInspectorProxy { /// `gracePeriod` waits indefinitely for the next page target. Throws only /// connection-terminal errors. package func waitForCurrentPageReplacement(gracePeriod: Duration?) async throws -> WebInspectorTarget? { - try ensureOpenForCurrentPageAccess() - guard let transport else { - return pageTarget - } + try await ensureOpenForCurrentPageAccess() do { - try await refreshCurrentPage(from: transport, timeout: gracePeriod) + return try await currentPageTarget(from: core, timeout: gracePeriod) } catch TransportSession.Error.missingMainPageTarget { return nil } catch { throw Self.mapBootstrapTargetError(error) } - try ensureOpenForCurrentPageAccess() - return pageTarget } package var bootstrapGracePeriod: Duration { configuration.bootstrapTimeout } - /// Reloads the currently inspected page without ignoring cache. - public func reload() async throws { - guard let pageTarget else { - throw WebInspectorProxyError.disconnected("WebInspectorProxyKit shell has no current page target.") - } - let _: Void = try await dispatchCommand( - targetID: pageTarget.id, - route: pageTarget.route, - domain: .page, - method: "reload", - payload: Page.ReloadPayload(ignoringCache: false) - ) - } - /// Closes the inspector connection. /// /// Calling `close()` more than once is allowed. Await /// ``waitUntilClosed()`` when another task needs to observe completion. public func close() async { - switch closeState { - case .open: - break - case .closing: - try? await waitUntilClosed() - return - case .closed: - return - } - closeState = .closing - pageTarget = nil - await closeConnection?() - closeState = .closed - resumeCloseWaiters() + await core.close() + } + + package func openModelFeed( + configuredDomains: Set, + onRegistered: (@Sendable (ConnectionModelFeed) async -> Bool)? = nil + ) async throws -> ConnectionModelFeed { + try await core.openModelFeed( + configuredDomains: configuredDomains, + onRegistered: onRegistered + ) } /// Suspends until ``close()`` has finished. @@ -270,53 +205,15 @@ public actor WebInspectorProxy { /// If the proxy is already closed, this method returns immediately. If the /// waiting task is cancelled, only that waiter is cancelled. public func waitUntilClosed() async throws { - guard closeState != .closed else { - return - } - nextCloseWaiterID &+= 1 - let waiterID = nextCloseWaiterID - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - registerCloseWaiter(id: waiterID, continuation: continuation) - } - } onCancel: { - Task { - await self.cancelCloseWaiter(waiterID) - } - } + try await core.waitUntilClosed() } - package func installTargetForTesting( - kind: WebInspectorTarget.Kind = .page, - frameID: FrameID? = nil, - isProvisional: Bool = false - ) -> WebInspectorTarget { - let ordinal = nextTargetOrdinal - nextTargetOrdinal += 1 - let target = WebInspectorTarget( - id: WebInspectorTarget.ID("test-target-\(ordinal)"), - kind: kind, - frameID: frameID, - isProvisional: isProvisional, - proxy: self, - route: RoutingTargetID("test-route-\(ordinal)") - ) - if kind == .page && isProvisional == false { - pageTarget = target - } - return target + package func waitForCloseWaiterForTesting() async { + await core.waitForCloseWaiterForTesting() } - package func waitForCloseWaiterForTesting() async { - guard closeState != .closed else { - preconditionFailure("Cannot wait for a close waiter after WebInspectorProxy closed.") - } - guard closeWaiters.isEmpty else { - return - } - await withCheckedContinuation { continuation in - closeWaiterRegistrationWaiters.append(continuation) - } + package func pageGeneration() async throws -> WebInspectorPage.Generation { + try await core.pageGeneration() } package func dispatchCommand( @@ -324,10 +221,15 @@ public actor WebInspectorProxy { route: RoutingTargetID, domain: WebInspectorProxyDomain, method: String, - payload: Payload + payload: Payload, + authority: WebInspectorCommandAuthority = .direct ) async throws -> Result { - guard closeState == .open else { + do { + try await core.requireOpen() + } catch TransportSession.Error.transportClosed { throw WebInspectorProxyError.closed + } catch let TransportSession.Error.transportFailure(message) { + throw WebInspectorProxyError.disconnected(message) } guard let backend else { throw unimplementedCommand(domain: domain.rawValue, method: method) @@ -341,260 +243,14 @@ public actor WebInspectorProxy { let command = WebInspectorProxyCommand( targetID: commandTarget.targetID, route: commandTarget.route, - resultTargetScopeRawValue: commandTarget.resultTargetScopeRawValue, domain: domain, method: method, - payload: payload + payload: payload, + authority: authority ) return try await backend.dispatchCommand(command) } - package nonisolated func domEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - guard let backend else { - preconditionFailure("WebInspectorProxy has no backend for DOM events.") - } - return AsyncStream { continuation in - let task = Task { - await withTaskGroup(of: Void.self) { group in - group.addTask { - for await event in backend.events(route: route, targetID: targetID, domain: .dom) { - guard case let .dom(value) = event else { - preconditionFailure("Backend emitted a mismatched event for DOM.") - } - continuation.yield(value) - } - } - group.addTask { - for await event in backend.events(route: route, targetID: targetID, domain: .inspector) { - guard case let .inspector(value) = event else { - preconditionFailure("Backend emitted a mismatched event for Inspector.") - } - await self.emitDOMInspectEvent( - for: value, - targetID: targetID, - route: route, - continuation: continuation - ) - } - } - await group.waitForAll() - continuation.finish() - } - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - - package nonisolated func cssEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .css) { event in - guard case let .css(value) = event else { - return nil - } - return value - } - } - - package nonisolated func networkEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .network) { event in - guard case let .network(value) = event else { - return nil - } - return value - } - } - - package nonisolated func consoleEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .console) { event in - guard case let .console(value) = event else { - return nil - } - return value.event - } - } - - package nonisolated func targetedConsoleEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .console) { event in - guard case let .console(value) = event else { - return nil - } - return value - } - } - - package nonisolated func runtimeEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - eventStream(targetID: targetID, route: route, domain: .runtime) { event in - guard case let .runtime(value) = event else { - return nil - } - return value - } - } - - package nonisolated func targetLifecycleEvents( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> AsyncStream { - guard let backend else { - preconditionFailure("WebInspectorProxy has no backend for lifecycle events.") - } - return AsyncStream { continuation in - let task = Task { - await withTaskGroup(of: Void.self) { group in - for domain in [WebInspectorProxyEventDomain.target, .page] { - group.addTask { - for await event in backend.events(route: route, targetID: targetID, domain: domain) { - guard case let .targetLifecycle(value) = event else { - preconditionFailure("Backend emitted a mismatched event for lifecycle.") - } - await self.applyTargetLifecycleEventToProxyState(value) - continuation.yield(value) - } - } - } - await group.waitForAll() - continuation.finish() - } - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - - package nonisolated func waitForEventSubscription( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID, - domain: WebInspectorProxyEventDomain - ) async { - guard let backend else { - preconditionFailure("WebInspectorProxy has no backend for \(domain.rawValue) events.") - } - await backend.waitForEventSubscription(route: route, targetID: targetID, domain: domain) - } - - private nonisolated func eventStream( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID, - domain: WebInspectorProxyEventDomain, - extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? - ) -> AsyncStream { - guard let backend else { - preconditionFailure("WebInspectorProxy has no backend for \(domain.rawValue) events.") - } - return AsyncStream { continuation in - let task = Task { - for await event in backend.events(route: route, targetID: targetID, domain: domain) { - guard let value = extract(event) else { - preconditionFailure("Backend emitted a mismatched event for \(domain.rawValue).") - } - continuation.yield(value) - } - continuation.finish() - } - continuation.onTermination = { _ in - task.cancel() - } - } - } - - private nonisolated func emitDOMInspectEvent( - for event: Inspector.Event, - targetID: WebInspectorTarget.ID, - route: RoutingTargetID, - continuation: AsyncStream.Continuation - ) async { - guard case let .inspect(object, _, origin) = event else { - return - } - guard object.subtype?.rawValue == "node", let objectID = object.id else { - logger.debug( - "Inspector.inspect ignored reason=non-node route=\(Self.logDescription(route), privacy: .public) subtype=\(String(describing: object.subtype), privacy: .public)" - ) - return - } - let targets = Self.inspectResolutionTargets(targetID: targetID, route: route, origin: origin) - logger.debug( - "Inspector.inspect resolving route=\(Self.logDescription(route), privacy: .public) objectID=\(objectID.rawValue, privacy: .public) commandTarget=\(targets.commandTargetID.rawValue, privacy: .public) commandRoute=\(Self.logDescription(targets.commandRoute), privacy: .public) projectionTarget=\(targets.projectionTargetID.rawValue, privacy: .public)" - ) - // WebKit's FrameDOMAgent does not implement requestNode. Even when an - // Inspector.inspect event is target-wrapped for a frame, the frontend - // asks the page DOM agent to translate the RemoteObject into a node id. - // The returned node still belongs to the inspect origin for current-page - // projection, so keep that scope when emitting DOM.inspect. - do { - let nodeID: DOM.Node.ID = try await dispatchCommand( - targetID: targets.commandTargetID, - route: targets.commandRoute, - domain: .dom, - method: "requestNode", - payload: DOM.RequestNodePayload(objectID: objectID) - ) - let projectedNodeID = Self.projectedDOMNodeID(nodeID, targetID: targets.projectionTargetID, route: route) - logger.debug( - "Inspector.inspect resolved objectID=\(objectID.rawValue, privacy: .public) nodeID=\(nodeID.rawValue, privacy: .public) projectedNodeID=\(projectedNodeID.rawValue, privacy: .public)" - ) - continuation.yield(.inspect(projectedNodeID)) - } catch { - logger.debug( - "Inspector.inspect requestNode failed objectID=\(objectID.rawValue, privacy: .public) commandTarget=\(targets.commandTargetID.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)" - ) - continuation.yield(.unknown(RawEvent(domain: "Inspector", method: "inspect"))) - } - } - - private nonisolated static func inspectResolutionTargets( - targetID: WebInspectorTarget.ID, - route: RoutingTargetID, - origin: Inspector.EventOrigin? - ) -> ( - commandTargetID: WebInspectorTarget.ID, - commandRoute: RoutingTargetID, - projectionTargetID: WebInspectorTarget.ID - ) { - guard route == .currentPage else { - let commandTargetID = origin?.targetID ?? targetID - return ( - commandTargetID: commandTargetID, - commandRoute: origin?.route ?? route, - projectionTargetID: commandTargetID - ) - } - return ( - commandTargetID: targetID, - commandRoute: route, - projectionTargetID: origin?.targetID ?? targetID - ) - } - - private nonisolated static func logDescription(_ route: RoutingTargetID) -> String { - switch route.storage { - case .currentPage: - return "current-page" - case let .target(rawValue): - return rawValue - } - } - private func resolvedCommandTarget( targetID: WebInspectorTarget.ID, route: RoutingTargetID, @@ -644,11 +300,10 @@ public actor WebInspectorProxy { route: RoutingTargetID(scopedTargetRawValue) ) } - if let requestNodeObjectID = Self.requestNodeObjectID(from: payload, domain: domain) { + if Self.isRequestNodePayload(from: payload, domain: domain) { return ProtocolCommandTarget( - targetID: targetID, - route: route, - resultTargetScopeRawValue: requestNodeObjectID.targetScopeRawValue + targetID: .currentPage, + route: .currentPage ) } if let remoteObjectID = Self.remoteObjectID(from: payload, domain: domain), @@ -661,19 +316,6 @@ public actor WebInspectorProxy { return ProtocolCommandTarget(targetID: targetID, route: route) } - private nonisolated static func projectedDOMNodeID( - _ nodeID: DOM.Node.ID, - targetID: WebInspectorTarget.ID, - route: RoutingTargetID - ) -> DOM.Node.ID { - guard route == .currentPage, - targetID != .currentPage, - nodeID.targetScopeRawValue == nil else { - return nodeID - } - return DOM.Node.ID(nodeID.rawValue, scopedToTargetRawValue: targetID.rawValue) - } - private nonisolated static func nodeID( from payload: Payload, domain: WebInspectorProxyDomain @@ -802,102 +444,40 @@ public actor WebInspectorProxy { } } - private nonisolated static func requestNodeObjectID( + private nonisolated static func isRequestNodePayload( from payload: Payload, domain: WebInspectorProxyDomain - ) -> Runtime.RemoteObject.ID? { - guard domain == .dom, - let payload = payload as? DOM.RequestNodePayload else { - return nil - } - return payload.objectID + ) -> Bool { + domain == .dom && payload is DOM.RequestNodePayload } private func bootstrapCurrentPage(from transport: TransportSession) async throws { - try await refreshCurrentPage(from: transport, timeout: configuration.bootstrapTimeout) + _ = try await currentPageTarget( + from: transport, + timeout: configuration.bootstrapTimeout + ) } - /// `timeout: nil` waits indefinitely for the next main page target. - private func refreshCurrentPage( + /// `timeout: nil` waits indefinitely for the next main page target. The + /// returned handle is materialized from the core's current record; no + /// proxy-owned current-target cache participates in routing. + private func currentPageTarget( from transport: TransportSession, timeout: Duration? - ) async throws { - let transportTarget: TransportSession.MainPageTarget - do { - transportTarget = try await transport.waitForCurrentMainPageTarget( - timeout: timeout - ) - } catch { - pageTarget = nil - try ensureOpenForCurrentPageAccess() - throw error - } - let snapshot = await transport.snapshot() - try ensureOpenForCurrentPageAccess() - guard let record = snapshot.targetsByID[transportTarget.targetID] else { + ) async throws -> WebInspectorTarget { + _ = try await transport.waitForCurrentMainPageTarget(timeout: timeout) + try await ensureOpenForCurrentPageAccess() + guard let record = await transport.currentMainPageRecord() else { throw WebInspectorProxyError.disconnected("Current page target disappeared during bootstrap.") } - pageTarget = try currentPageTarget(from: record) - } - - private func ensureOpenForCurrentPageAccess() throws { - guard closeState == .open else { - pageTarget = nil - throw WebInspectorProxyError.closed - } - } - - private func applyTargetLifecycleEventToProxyState(_ event: WebInspectorTargetLifecycleEvent) { - guard closeState == .open else { - return - } - switch event { - case let .didCommitProvisionalTarget(commit) where commit.newTarget.id == .currentPage: - pageTarget = currentPageTarget(from: commit.newTarget) - case let .targetDestroyed(targetID) where targetID == .currentPage: - pageTarget = nil - default: - break - } - } - - private func registerCloseWaiter(id: UInt64, continuation: CheckedContinuation) { - guard closeState != .closed else { - continuation.resume() - return - } - guard cancelledCloseWaiterIDs.remove(id) == nil else { - continuation.resume(throwing: CancellationError()) - return - } - closeWaiters[id] = continuation - resumeCloseWaiterRegistrationWaiters() + return try currentPageTarget(from: record) } - private func cancelCloseWaiter(_ id: UInt64) { - guard let continuation = closeWaiters.removeValue(forKey: id) else { - if closeState != .closed { - cancelledCloseWaiterIDs.insert(id) - } - return - } - continuation.resume(throwing: CancellationError()) - } - - private func resumeCloseWaiters() { - let waiters = closeWaiters.values - closeWaiters.removeAll() - cancelledCloseWaiterIDs.removeAll() - for waiter in waiters { - waiter.resume() - } - } - - private func resumeCloseWaiterRegistrationWaiters() { - let waiters = closeWaiterRegistrationWaiters - closeWaiterRegistrationWaiters.removeAll() - for waiter in waiters { - waiter.resume() + private func ensureOpenForCurrentPageAccess() async throws { + do { + try await core.requireOpen() + } catch { + throw Self.mapBootstrapTargetError(error) } } @@ -916,18 +496,6 @@ public actor WebInspectorProxy { ) } - private func currentPageTarget(from target: WebInspectorLifecycleTarget) -> WebInspectorTarget { - WebInspectorTarget( - id: target.id, - kind: target.kind, - frameID: target.frameID, - isProvisional: target.isProvisional, - proxy: self, - route: .currentPage, - pageBindingID: target.pageBindingID - ) - } - private nonisolated static func mapBootstrapTargetError(_ error: any Error) -> any Error { guard let transportError = error as? TransportSession.Error else { return error @@ -937,6 +505,8 @@ public actor WebInspectorProxy { return WebInspectorProxyError.timeout(domain: "Target", method: "waitForCurrentPage") case .transportClosed: return WebInspectorProxyError.closed + case let .transportFailure(message): + return WebInspectorProxyError.disconnected(message) case let .replyTimeout(method, _): return WebInspectorProxyError.timeout(domain: "Target", method: method) case let .remoteError(method, _, message): @@ -972,3 +542,27 @@ public actor WebInspectorProxy { return WebInspectorProxyError.attachFailed(String(describing: error)) } } + +private struct UnavailableTransportBackend: TransportBackend { + func sendJSONString(_ message: String) async throws { + _ = message + throw TransportSession.Error.transportClosed + } + + func detach() async {} +} + +private extension WebInspectorTarget.Kind { + var protocolKind: ProtocolTarget.Kind { + switch self { + case .page: + .page + case .frame: + .frame + case .worker: + .worker + case .serviceWorker: + .serviceWorker + } + } +} diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift index 118c99b6..4785b1ba 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyBackend.swift @@ -24,25 +24,25 @@ package enum WebInspectorProxyEventDomain: String, Hashable, Sendable { package struct WebInspectorProxyCommand: Sendable { package let targetID: WebInspectorTarget.ID package let route: RoutingTargetID - package let resultTargetScopeRawValue: String? package let domain: WebInspectorProxyDomain package let method: String package let payload: Payload + package let authority: WebInspectorCommandAuthority package init( targetID: WebInspectorTarget.ID, route: RoutingTargetID, - resultTargetScopeRawValue: String? = nil, domain: WebInspectorProxyDomain, method: String, - payload: Payload + payload: Payload, + authority: WebInspectorCommandAuthority = .direct ) { self.targetID = targetID self.route = route - self.resultTargetScopeRawValue = resultTargetScopeRawValue self.domain = domain self.method = method self.payload = payload + self.authority = authority } } @@ -61,15 +61,37 @@ package protocol WebInspectorProxyBackend: Sendable { _ command: WebInspectorProxyCommand ) async throws -> Result - nonisolated func events( + func acquireEventScope( route: RoutingTargetID, targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) -> AsyncStream + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) async throws -> WebInspectorProxyEventScope - func waitForEventSubscription( + func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws +} + +package extension WebInspectorProxyBackend { + func acquireEventScope( route: RoutingTargetID, targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) async + domain: WebInspectorProxyEventDomain, + buffering: WebInspectorEventBufferingPolicy, + extract: @escaping @Sendable (WebInspectorProxyEvent) -> Element? + ) async throws -> WebInspectorProxyEventScope { + _ = route + _ = targetID + _ = buffering + _ = extract + throw WebInspectorProxyError.commandFailed( + domain: domain.rawValue, + method: "withEvents", + message: "This backend does not implement structured event scopes." + ) + } + + func releaseEventScope(_ id: WebInspectorProxyEventScopeID) async throws { + _ = id + } } diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift b/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift index 25340334..5352feba 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyError.swift @@ -11,12 +11,33 @@ public enum WebInspectorProxyError: Error, Sendable, Equatable { /// The proxy was closed before the requested operation completed. case closed + /// No physical page target is currently available for the logical page. + case pageUnavailable + + /// A target-scoped identifier belongs to an older page generation. + case staleIdentifier + /// The inspector connection disconnected. case disconnected(String) /// A protocol command failed in the backend. case commandFailed(domain: String, method: String, message: String) + /// The inspected target rejected a protocol command. + case commandRejected(method: String, message: String) + + /// A known protocol envelope or event payload was malformed. + case protocolViolation(String) + + /// A structured event subscriber could not retain another pending event. + case eventBufferOverflow(capacity: Int) + + /// Another exclusive consumer already owns this connection. + case connectionInUse + + /// The underlying inspector transport failed. + case transportFailure(String) + /// A protocol command did not receive a reply before its timeout. case timeout(domain: String, method: String) } diff --git a/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md b/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md index ab47044e..5e461e8a 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md +++ b/Sources/WebInspectorProxyKit/WebInspectorProxyKit.docc/WebInspectorProxyKit.md @@ -6,10 +6,11 @@ Typed Web Inspector protocol transport for an inspected `WKWebView`. Use WebInspectorProxyKit when you want direct access to WebKit's inspector protocol commands and events. ProxyKit attaches to a `WKWebView`, tracks the -current page target, and exposes typed domain clients from ``WebInspectorTarget``. +current physical page internally, and exposes typed domain handles from one +stable ``WebInspectorPage``. -Create a ``WebInspectorProxy``, wait for the current page, and send protocol -commands through target-scoped clients: +Create a ``WebInspectorProxy`` and send protocol commands through its logical +page handle: ```swift import WebKit @@ -24,23 +25,25 @@ func printPageTitle(from webView: WKWebView) async throws { } } - let page = try await proxy.waitForCurrentPage() - try await page.runtime.enable() - - let evaluation = try await page.runtime.evaluate("document.title") + let evaluation = try await proxy.page.runtime.evaluate("document.title") print(evaluation.object.description ?? "") } ``` -Domain clients can also expose event streams. Enable the domain before consuming -events that require WebKit to start reporting that domain: +Domain clients expose atomically registered event scopes. The scope registers +its subscriber before WebKit domain activation and balances deactivation when +the operation finishes: ```swift -let page = try await proxy.waitForCurrentPage() -try await page.network.enable() - -for await event in page.network.events { - handleNetworkEvent(event) +try await proxy.page.network.withEvents { events in + for try await pageEvent in events { + switch pageEvent { + case .reset: + resetNetworkPresentation() + case let .event(_, event): + handleNetworkEvent(event) + } + } } ``` @@ -54,7 +57,7 @@ selection, collection updates, and DOM tree snapshots. - ``WebInspectorProxy`` - ``WebInspectorProxy/Configuration`` -- ``WebInspectorTarget`` +- ``WebInspectorPage`` - ``WebInspectorProxyError`` ### Protocol Domains @@ -66,7 +69,9 @@ selection, collection updates, and DOM tree snapshots. - ``Runtime`` - ``Page`` -### Events and Targets +### Events and Identity +- ``WebInspectorPageEvent`` +- ``WebInspectorEventBufferingPolicy`` - ``RawEvent`` - ``FrameID`` diff --git a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift index 17c4d87b..f7f1ef26 100644 --- a/Sources/WebInspectorProxyKit/WebInspectorTarget.swift +++ b/Sources/WebInspectorProxyKit/WebInspectorTarget.swift @@ -37,14 +37,10 @@ package struct RoutingTargetID: Hashable, Sendable { } } -/// A typed handle for a Web Inspector protocol target. -/// -/// Targets vend domain clients such as ``dom``, ``network``, and ``runtime``. -/// Keep the target that DataKit or ProxyKit gives you instead of constructing -/// transport target identifiers yourself. -public struct WebInspectorTarget: Identifiable, Sendable { - /// Stable identity for a protocol target within one proxy connection. - public struct ID: Hashable, Sendable { +/// A package-owned physical WebKit target used while projecting one logical +/// ``WebInspectorPage``. Public consumers never own physical target identity. +package struct WebInspectorTarget: Identifiable, Sendable { + package struct ID: Hashable, Sendable { package let rawValue: String package init(_ rawValue: String) { @@ -54,37 +50,29 @@ public struct WebInspectorTarget: Identifiable, Sendable { package static let currentPage = ID("current-page") } - /// The kind of backend target represented by a ``WebInspectorTarget``. - public enum Kind: Sendable { - /// A top-level page target. + package enum Kind: Equatable, Sendable { case page - - /// A frame target. case frame - - /// A worker target. case worker - - /// A service worker target. case serviceWorker } - /// The target identity used by typed domain clients. - public let id: ID - - /// The backend target kind. - public let kind: Kind + package let id: ID + package let kind: Kind + package let frameID: FrameID? + package let isProvisional: Bool - /// The frame identifier for frame-backed targets. - public let frameID: FrameID? - - /// A Boolean value indicating whether the target is provisional during - /// navigation. - public let isProvisional: Bool - - package let proxy: WebInspectorProxy + package let proxyReference: WebInspectorProxyReference package let route: RoutingTargetID package let pageBindingID: String? + package let authority: WebInspectorCommandAuthority + + package var proxy: WebInspectorProxy { + guard let proxy = proxyReference.resolve() else { + preconditionFailure("A package-only binding check outlived its WebInspectorProxy owner.") + } + return proxy + } package init( id: ID, @@ -93,15 +81,37 @@ public struct WebInspectorTarget: Identifiable, Sendable { isProvisional: Bool, proxy: WebInspectorProxy, route: RoutingTargetID, - pageBindingID: String? = nil + pageBindingID: String? = nil, + authority: WebInspectorCommandAuthority = .direct ) { self.id = id self.kind = kind self.frameID = frameID self.isProvisional = isProvisional - self.proxy = proxy + proxyReference = WebInspectorProxyReference(proxy) self.route = route self.pageBindingID = pageBindingID + self.authority = authority + } + + private init( + id: ID, + kind: Kind, + frameID: FrameID?, + isProvisional: Bool, + proxyReference: WebInspectorProxyReference, + route: RoutingTargetID, + pageBindingID: String?, + authority: WebInspectorCommandAuthority + ) { + self.id = id + self.kind = kind + self.frameID = frameID + self.isProvisional = isProvisional + self.proxyReference = proxyReference + self.route = route + self.pageBindingID = pageBindingID + self.authority = authority } package func withPageBinding(from lifecycleTarget: WebInspectorLifecycleTarget) -> WebInspectorTarget { @@ -110,58 +120,48 @@ public struct WebInspectorTarget: Identifiable, Sendable { kind: lifecycleTarget.kind, frameID: lifecycleTarget.frameID, isProvisional: lifecycleTarget.isProvisional, - proxy: proxy, + proxyReference: proxyReference, route: route, - pageBindingID: lifecycleTarget.pageBindingID + pageBindingID: lifecycleTarget.pageBindingID, + authority: authority ) } - /// A typed client for DOM protocol commands and events. - public var dom: DOM.Client { - DOM.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) - } - - /// A typed client for CSS protocol commands and events. - public var css: CSS.Client { - CSS.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) - } - - /// A typed client for Network protocol commands and events. - public var network: Network.Client { - Network.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var dom: DOM { + DOM(endpoint: endpoint) } - /// A typed client for Console protocol commands and events. - public var console: Console.Client { - Console.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var css: CSS { + CSS(endpoint: endpoint) } - /// A typed client for Runtime protocol commands and events. - public var runtime: Runtime.Client { - Runtime.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var network: Network { + Network(endpoint: endpoint) } - /// A typed client for Page protocol commands. - public var page: Page.Client { - Page.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var console: Console { + Console(endpoint: endpoint) } - package var inspector: Inspector.Client { - Inspector.Client(context: DomainClientContext(proxy: proxy, targetID: id, route: route)) + package var runtime: Runtime { + Runtime(endpoint: endpoint) } - package var lifecycleEvents: AsyncStream { - proxy.targetLifecycleEvents(targetID: id, route: route) + package var page: Page { + Page(endpoint: endpoint) } - package var targetedConsoleEvents: AsyncStream { - proxy.targetedConsoleEvents(targetID: id, route: route) + package var inspector: Inspector { + Inspector(endpoint: endpoint) } - package func waitForModelEventSubscriptions() async { - for domain in [WebInspectorProxyEventDomain.dom, .inspector, .css, .network, .console, .runtime] { - await proxy.waitForEventSubscription(targetID: id, route: route, domain: domain) - } + private var endpoint: DomainEndpoint { + DomainEndpoint( + proxyReference: proxyReference, + targetID: id, + route: route, + authority: authority + ) } } @@ -176,66 +176,19 @@ package extension WebInspectorProxy { route: RoutingTargetID(id.rawValue) ) } -} - -package struct DomainClientContext: Sendable { - package let proxy: WebInspectorProxy - package let targetID: WebInspectorTarget.ID - package let route: RoutingTargetID - - package init(proxy: WebInspectorProxy, targetID: WebInspectorTarget.ID, route: RoutingTargetID) { - self.proxy = proxy - self.targetID = targetID - self.route = route - } - - package func dispatch( - domain: WebInspectorProxyDomain, - method: String, - payload: Payload, - returning resultType: Result.Type = Result.self - ) async throws -> Result { - _ = resultType - return try await proxy.dispatchCommand( - targetID: targetID, - route: route, - domain: domain, - method: method, - payload: payload - ) - } - package func dispatchVoid( - domain: WebInspectorProxyDomain, - method: String, - payload: Payload - ) async throws { - let _: Void = try await dispatch( - domain: domain, - method: method, - payload: payload, - returning: Void.self + nonisolated func modelTarget( + _ target: ModelTarget, + authorization: ConnectionModelCommandAuthorization + ) -> WebInspectorTarget { + WebInspectorTarget( + id: target.id, + kind: target.kind, + frameID: target.frameID, + isProvisional: false, + proxy: self, + route: RoutingTargetID(target.id.rawValue), + authority: .modelFeed(authorization) ) } - - package func domEvents() -> AsyncStream { - proxy.domEvents(targetID: targetID, route: route) - } - - package func cssEvents() -> AsyncStream { - proxy.cssEvents(targetID: targetID, route: route) - } - - package func networkEvents() -> AsyncStream { - proxy.networkEvents(targetID: targetID, route: route) - } - - package func consoleEvents() -> AsyncStream { - proxy.consoleEvents(targetID: targetID, route: route) - } - - package func runtimeEvents() -> AsyncStream { - proxy.runtimeEvents(targetID: targetID, route: route) - } - } diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md new file mode 100644 index 00000000..08aaf784 --- /dev/null +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyKitTesting.docc/WebInspectorProxyKitTesting.md @@ -0,0 +1,81 @@ +# ``WebInspectorProxyKitTesting`` + +Drive ProxyKit's production connection path from a concrete raw WebKit peer. + +## Overview + +`WebInspectorProxyKitTesting` replaces the native WebKit transport, not +ProxyKit's semantics. ``WebInspectorProxyTestRuntime`` connects +``WebInspectorTestPeer`` below the real connection core, so target discovery, +routing, command and event JSON codecs, reply correlation, model feeds, and +command authority all run exactly as they do for a native attachment. + +Start a runtime, perform work through the real proxy, then receive and complete +the resulting raw command: + +```swift +import WebInspectorProxyKitTesting + +let runtime = try await WebInspectorProxyTestRuntime.start() + +let reload = Task { + try await runtime.page.page.reload() +} + +let command = try await runtime.peer.commands.next() +precondition(command.destination == .target("page-main")) +precondition(command.method == "Page.reload") + +try await runtime.peer.reply(to: command) +try await reload.value +await runtime.close() +``` + +Every ``WebInspectorTestPeer/Command`` carries an opaque correlation. Reply or +fail that exact value once. Targeted replies automatically produce WebKit's +outer `Target.sendMessageToTarget` acknowledgement and inner target result. +Reusing, mixing, or retaining correlations across peer connections fails +explicitly. + +Use ``WebInspectorTestJSONObject`` for validated raw objects. It rejects +non-object top-level JSON and stores canonical sorted-key bytes: + +```swift +let parameters = try WebInspectorTestJSONObject( + json: #"{"requestId":"request-1","timestamp":3}"# +) + +try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Network.loadingFinished", + parameters: parameters +) +``` + +Target creation, provisional commits, destruction, clean remote EOF, and fatal +transport failure also enter through the peer's raw transport boundary. Call +``WebInspectorProxyTestRuntime/close()`` and await it as the normal ownership +endpoint for every test runtime. + +The testing product intentionally has no semantic backend, subscriber-state +injection, event sequence markers, generations, or synthetic model snapshots. +Build fixtures at the wire boundary so tests exercise the same contracts that +production data crosses. + +## Topics + +### Runtime Ownership + +- ``WebInspectorProxyTestRuntime`` + +### Raw Peer + +- ``WebInspectorTestPeer`` +- ``WebInspectorTestPeer/Command`` +- ``WebInspectorTestPeer/Target`` +- ``WebInspectorTestPeerError`` + +### JSON and Value Fixtures + +- ``WebInspectorTestJSONObject`` +- ``WebInspectorProxyTestFixtures`` diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyTestRuntime.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyTestRuntime.swift index 53d706d2..9fe03a8b 100644 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorProxyTestRuntime.swift +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorProxyTestRuntime.swift @@ -1,25 +1,56 @@ -import Foundation import WebInspectorProxyKit -/// In-memory proxy runtime for tests. +/// A production-path ProxyKit runtime controlled by a raw WebKit test peer. +/// +/// The runtime is the explicit resource owner for tests. Call ``close()`` and +/// await its completion before releasing the runtime. public struct WebInspectorProxyTestRuntime: Sendable { - /// The proxy under test. - public var proxy: WebInspectorProxy + /// The real ProxyKit connection driven through `ConnectionCore`. + public let proxy: WebInspectorProxy - /// The controllable backend attached to the proxy. - public var backend: WebInspectorTestBackend + /// The raw-wire WebKit peer attached below `ConnectionCore`. + public let peer: WebInspectorTestPeer - /// Creates a test runtime from an existing proxy and backend. - public init(proxy: WebInspectorProxy, backend: WebInspectorTestBackend) { - self.proxy = proxy - self.backend = backend + /// The stable logical page created by the proxy. + public let page: WebInspectorPage + + /// Starts a production-path proxy and installs one initial physical page + /// target through a raw `Target.targetCreated` event. + public static func start( + configuration: WebInspectorProxy.Configuration = .init(), + initialTarget: WebInspectorTestPeer.Target = .initialPage + ) async throws -> WebInspectorProxyTestRuntime { + let peer = WebInspectorTestPeer() + let core = await peer.makeConnection(configuration: configuration) + do { + try await peer.createTarget(initialTarget) + let proxy = try await WebInspectorProxy( + transport: core, + configuration: configuration + ) + return WebInspectorProxyTestRuntime( + proxy: proxy, + peer: peer, + page: proxy.page + ) + } catch { + await core.close() + throw error + } } - /// Starts a proxy backed by ``WebInspectorTestBackend`` and installs a page target. - public static func start() async throws -> WebInspectorProxyTestRuntime { - let backend = WebInspectorTestBackend() - let proxy = WebInspectorProxy(backend: backend) - _ = await proxy.installTargetForTesting(kind: .page) - return WebInspectorProxyTestRuntime(proxy: proxy, backend: backend) + /// Closes the owned proxy connection and waits for peer detachment. + public func close() async { + await proxy.close() + } + + private init( + proxy: WebInspectorProxy, + peer: WebInspectorTestPeer, + page: WebInspectorPage + ) { + self.proxy = proxy + self.peer = peer + self.page = page } } diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift deleted file mode 100644 index 31c6391f..00000000 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorTestBackend.swift +++ /dev/null @@ -1,582 +0,0 @@ -import Foundation -import WebInspectorProxyKit - -package struct AnyRecordedValue: @unchecked Sendable { - package let value: Any - - package init(_ value: some Sendable) { - self.value = value - } - - package func cast(as type: T.Type = T.self) -> T? { - value as? T - } -} - -/// A command recorded by ``WebInspectorTestBackend``. -public struct RecordedCommand: Equatable, Sendable { - /// The target that received the command. - public let targetID: WebInspectorTarget.ID - - /// The protocol domain for the command. - public let domain: String - - /// The protocol method for the command. - public let method: String - package let route: RoutingTargetID - package let payload: AnyRecordedValue - - /// Creates an unscoped recorded command used for equality assertions. - public init(domain: String, method: String) { - targetID = WebInspectorTarget.ID("unscoped-recorded-command") - route = RoutingTargetID("unscoped-recorded-command") - self.domain = domain - self.method = method - payload = AnyRecordedValue(()) - } - - package init( - command: WebInspectorProxyCommand - ) { - targetID = command.targetID - route = command.route - domain = command.domain.rawValue - method = command.method - payload = AnyRecordedValue(command.payload) - } - - /// Compares recorded commands by domain and method. - public static func == (lhs: RecordedCommand, rhs: RecordedCommand) -> Bool { - lhs.domain == rhs.domain && lhs.method == rhs.method - } -} - -private struct HeldCommand: Sendable { - var domain: String - var method: String - var gate: WebInspectorTestGate -} - -private struct CommandKey: Hashable, Sendable { - var domain: String - var method: String -} - -private struct QueuedReply: @unchecked Sendable { - enum Storage { - case result(Any) - case failure(any Error) - } - - var storage: Storage - - init(_ value: some Sendable) { - storage = .result(value) - } - - init(failure error: any Error & Sendable) { - storage = .failure(error) - } -} - -private struct EventSubscriptionKey: Hashable, Sendable { - var route: RoutingTargetID - var targetID: WebInspectorTarget.ID - var domain: WebInspectorProxyEventDomain -} - -private struct SubscriberWaiter: Sendable { - var id: UInt64 - var route: RoutingTargetID? - var targetID: WebInspectorTarget.ID - var domain: WebInspectorProxyEventDomain - var count: Int - var continuation: CheckedContinuation -} - -private struct RecordedCommandWaiter: Sendable { - var domain: String - var method: String - var count: Int - var continuation: CheckedContinuation<[RecordedCommand], Never> -} - -private struct CompletedCommandWaiter: Sendable { - var domain: String - var method: String - var count: Int - var continuation: CheckedContinuation<[RecordedCommand], Never> -} - -/// Errors thrown by ``WebInspectorTestBackend`` helpers. -public enum WebInspectorTestBackendError: Error, Equatable, Sendable { - /// The requested event domain is not supported by the test backend. - case unsupportedEventDomain(String) -} - -/// Controllable in-memory backend for `WebInspectorProxyKit` tests. -public actor WebInspectorTestBackend { - private var enqueuedReplies: [CommandKey: [QueuedReply]] - private var commands: [RecordedCommand] - private var completedCommands: [RecordedCommand] - private var heldCommands: [HeldCommand] - private var eventContinuations: [EventSubscriptionKey: [UUID: AsyncStream.Continuation]] - private var subscriberWaiters: [SubscriberWaiter] - private var recordedCommandWaiters: [RecordedCommandWaiter] - private var completedCommandWaiters: [CompletedCommandWaiter] - private var nextSubscriberWaiterID: UInt64 - private var cancelledSubscriberWaiterIDs: Set - - /// Creates an empty test backend. - public init() { - enqueuedReplies = [:] - commands = [] - completedCommands = [] - heldCommands = [] - eventContinuations = [:] - subscriberWaiters = [] - recordedCommandWaiters = [] - completedCommandWaiters = [] - nextSubscriberWaiterID = 0 - cancelledSubscriberWaiterIDs = [] - } - - /// Enqueues a successful reply for the next matching command. - public func enqueue( - _ result: Result, - for domain: String, - method: String - ) async { - let key = CommandKey(domain: domain, method: method) - enqueuedReplies[key, default: []].append(QueuedReply(result)) - } - - /// Enqueues a failing reply for the next matching command. - public func enqueueFailure( - _ error: any Error & Sendable, - for domain: String, - method: String - ) async { - let key = CommandKey(domain: domain, method: method) - enqueuedReplies[key, default: []].append(QueuedReply(failure: error)) - } - - /// Emits a Network event to subscribers for a target identity. - public func emit(_ event: Network.Event, target: WebInspectorTarget.ID) async { - emit(.network(event), target: target, route: nil, domain: .network) - } - - /// Emits a Network event to subscribers for a target. - public func emit(_ event: Network.Event, target: WebInspectorTarget) async { - emit(.network(event), target: target.id, route: target.route, domain: .network) - } - - /// Emits a DOM event to subscribers for a target identity. - public func emit(_ event: DOM.Event, target: WebInspectorTarget.ID) async { - emit(.dom(event), target: target, route: nil, domain: .dom) - } - - /// Emits a DOM event to subscribers for a target. - public func emit(_ event: DOM.Event, target: WebInspectorTarget) async { - emit(.dom(event), target: target.id, route: target.route, domain: .dom) - } - - package func emit(_ event: Inspector.Event, target: WebInspectorTarget.ID) async { - emit(.inspector(event), target: target, route: nil, domain: .inspector) - } - - package func emit(_ event: Inspector.Event, target: WebInspectorTarget) async { - emit(.inspector(event), target: target.id, route: target.route, domain: .inspector) - } - - /// Emits a CSS event to subscribers for a target identity. - public func emit(_ event: CSS.Event, target: WebInspectorTarget.ID) async { - emit(.css(event), target: target, route: nil, domain: .css) - } - - /// Emits a CSS event to subscribers for a target. - public func emit(_ event: CSS.Event, target: WebInspectorTarget) async { - emit(.css(event), target: target.id, route: target.route, domain: .css) - } - - /// Emits a Console event to subscribers for a target identity. - public func emit(_ event: Console.Event, target: WebInspectorTarget.ID) async { - emit(.console(Console.TargetedEvent(event: event, targetID: target)), target: target, route: nil, domain: .console) - } - - /// Emits a Console event to subscribers for a target. - public func emit(_ event: Console.Event, target: WebInspectorTarget) async { - emit( - .console(Console.TargetedEvent(event: event, targetID: target.id)), - target: target.id, - route: target.route, - domain: .console - ) - } - - /// Emits a Runtime event to subscribers for a target identity. - public func emit(_ event: Runtime.Event, target: WebInspectorTarget.ID) async { - emit(.runtime(event), target: target, route: nil, domain: .runtime) - } - - /// Emits a Runtime event to subscribers for a target. - public func emit(_ event: Runtime.Event, target: WebInspectorTarget) async { - emit(.runtime(event), target: target.id, route: target.route, domain: .runtime) - } - - package func emit(_ event: WebInspectorTargetLifecycleEvent, target: WebInspectorTarget) async { - emit(.targetLifecycle(event), target: target.id, route: target.route, domain: lifecycleDomain(for: event)) - } - - /// Returns commands recorded by the backend. - public func recordedCommands() async -> [RecordedCommand] { - commands - } - - /// Returns commands whose backend dispatch has completed. - public func completedCommands() async -> [RecordedCommand] { - completedCommands - } - - /// Waits until at least the requested number of matching commands has been recorded. - public func waitForRecordedCommands( - domain: String, - method: String, - count: Int - ) async -> [RecordedCommand] { - let matches = recordedCommands(domain: domain, method: method) - guard matches.count < count else { - return matches - } - return await withCheckedContinuation { continuation in - let matches = recordedCommands(domain: domain, method: method) - if matches.count >= count { - continuation.resume(returning: matches) - } else { - recordedCommandWaiters.append(RecordedCommandWaiter( - domain: domain, - method: method, - count: count, - continuation: continuation - )) - } - } - } - - /// Waits until at least the requested number of matching commands has completed backend dispatch. - public func waitForCompletedCommands( - domain: String, - method: String, - count: Int - ) async -> [RecordedCommand] { - let matches = completedCommands(domain: domain, method: method) - guard matches.count < count else { - return matches - } - return await withCheckedContinuation { continuation in - let matches = completedCommands(domain: domain, method: method) - if matches.count >= count { - continuation.resume(returning: matches) - } else { - completedCommandWaiters.append(CompletedCommandWaiter( - domain: domain, - method: method, - count: count, - continuation: continuation - )) - } - } - } - - /// Waits until a target identity has at least the requested subscriber count. - public func waitForSubscribers( - domain: String, - target: WebInspectorTarget.ID, - count: Int - ) async throws { - guard let eventDomain = WebInspectorProxyEventDomain(rawValue: domain) else { - throw WebInspectorTestBackendError.unsupportedEventDomain(domain) - } - await waitForSubscriber(route: nil, targetID: target, domain: eventDomain, count: count) - } - - /// Waits until a target has at least the requested subscriber count. - public func waitForSubscribers( - domain: String, - target: WebInspectorTarget, - count: Int - ) async throws { - guard let eventDomain = WebInspectorProxyEventDomain(rawValue: domain) else { - throw WebInspectorTestBackendError.unsupportedEventDomain(domain) - } - await waitForSubscriber(route: target.route, targetID: target.id, domain: eventDomain, count: count) - } - - /// Holds matching commands until the supplied gate opens. - public func hold(domain: String, method: String, gate: WebInspectorTestGate) async { - heldCommands.append(HeldCommand(domain: domain, method: method, gate: gate)) - } - - private func emit( - _ event: WebInspectorProxyEvent, - target targetID: WebInspectorTarget.ID, - route: RoutingTargetID?, - domain: WebInspectorProxyEventDomain - ) { - let key = EventSubscriptionKey( - route: route ?? unambiguousRoute(for: targetID, domain: domain), - targetID: targetID, - domain: domain - ) - for continuation in eventContinuations[key, default: [:]].values { - continuation.yield(event) - } - } - - private func addEventContinuation( - _ continuation: AsyncStream.Continuation, - id: UUID, - key: EventSubscriptionKey - ) { - eventContinuations[key, default: [:]][id] = continuation - resolveSubscriberWaiters() - } - - private func removeEventContinuation(id: UUID, key: EventSubscriptionKey) { - eventContinuations[key]?[id] = nil - if eventContinuations[key]?.isEmpty == true { - eventContinuations[key] = nil - } - } - - private func subscriberCount(for key: EventSubscriptionKey) -> Int { - eventContinuations[key]?.count ?? 0 - } - - private func subscriberCount(for targetID: WebInspectorTarget.ID, domain: WebInspectorProxyEventDomain) -> Int { - eventContinuations.reduce(into: 0) { count, entry in - if entry.key.targetID == targetID && entry.key.domain == domain { - count += entry.value.count - } - } - } - - private func unambiguousRoute( - for targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain, - matching keys: [EventSubscriptionKey]? = nil - ) -> RoutingTargetID { - let matchingKeys = keys ?? eventContinuations.keys.filter { - $0.targetID == targetID && $0.domain == domain - } - let routes = Set(matchingKeys.map(\.route)) - guard routes.count <= 1 else { - preconditionFailure( - "Multiple routes are subscribed for \(domain.rawValue) target \(targetID); emit with WebInspectorTarget." - ) - } - guard let route = routes.first else { - preconditionFailure("No route is subscribed for \(domain.rawValue) target \(targetID).") - } - return route - } - - private func resolveSubscriberWaiters() { - var unresolved: [SubscriberWaiter] = [] - for waiter in subscriberWaiters { - if subscriberCount(for: waiter) >= waiter.count { - waiter.continuation.resume() - } else { - unresolved.append(waiter) - } - } - subscriberWaiters = unresolved - } - - private func waitForSubscriber( - route: RoutingTargetID?, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain, - count: Int - ) async { - let waiterID = nextSubscriberWaiterID - nextSubscriberWaiterID += 1 - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - addSubscriberWaiter(SubscriberWaiter( - id: waiterID, - route: route, - targetID: targetID, - domain: domain, - count: count, - continuation: continuation - )) - } - } onCancel: { - Task { - await self.cancelSubscriberWaiter(waiterID) - } - } - cancelledSubscriberWaiterIDs.remove(waiterID) - } - - private func addSubscriberWaiter(_ waiter: SubscriberWaiter) { - guard cancelledSubscriberWaiterIDs.remove(waiter.id) == nil else { - waiter.continuation.resume() - return - } - guard subscriberCount(for: waiter) < waiter.count else { - waiter.continuation.resume() - return - } - subscriberWaiters.append(waiter) - } - - private func cancelSubscriberWaiter(_ id: UInt64) { - guard let index = subscriberWaiters.firstIndex(where: { $0.id == id }) else { - cancelledSubscriberWaiterIDs.insert(id) - return - } - let waiter = subscriberWaiters.remove(at: index) - waiter.continuation.resume() - } - - private func subscriberCount(for waiter: SubscriberWaiter) -> Int { - if let route = waiter.route { - subscriberCount(for: EventSubscriptionKey( - route: route, - targetID: waiter.targetID, - domain: waiter.domain - )) - } else { - subscriberCount(for: waiter.targetID, domain: waiter.domain) - } - } - - private func recordedCommands(domain: String, method: String) -> [RecordedCommand] { - commands.filter { $0.domain == domain && $0.method == method } - } - - private func completedCommands(domain: String, method: String) -> [RecordedCommand] { - completedCommands.filter { $0.domain == domain && $0.method == method } - } - - private func resolveRecordedCommandWaiters() { - var unresolved: [RecordedCommandWaiter] = [] - for waiter in recordedCommandWaiters { - let matches = recordedCommands(domain: waiter.domain, method: waiter.method) - if matches.count >= waiter.count { - waiter.continuation.resume(returning: matches) - } else { - unresolved.append(waiter) - } - } - recordedCommandWaiters = unresolved - } - - private func recordCompletedCommand(_ command: RecordedCommand) { - completedCommands.append(command) - resolveCompletedCommandWaiters() - } - - private func resolveCompletedCommandWaiters() { - var unresolved: [CompletedCommandWaiter] = [] - for waiter in completedCommandWaiters { - let matches = completedCommands(domain: waiter.domain, method: waiter.method) - if matches.count >= waiter.count { - waiter.continuation.resume(returning: matches) - } else { - unresolved.append(waiter) - } - } - completedCommandWaiters = unresolved - } -} - -private func lifecycleDomain(for event: WebInspectorTargetLifecycleEvent) -> WebInspectorProxyEventDomain { - switch event { - case .didCommitProvisionalTarget, .targetDestroyed: - .target - case .frameNavigated, .frameDetached: - .page - case .unknown: - .target - } -} - -extension WebInspectorTestBackend: WebInspectorProxyBackend { - package func dispatchCommand( - _ command: WebInspectorProxyCommand - ) async throws -> Result { - let recordedCommand = RecordedCommand(command: command) - commands.append(recordedCommand) - resolveRecordedCommandWaiters() - defer { - recordCompletedCommand(recordedCommand) - } - - if let gate = heldCommands.first(where: { - $0.domain == command.domain.rawValue && $0.method == command.method - })?.gate { - await gate.wait() - } - - let key = CommandKey(domain: command.domain.rawValue, method: command.method) - guard var results = enqueuedReplies[key], results.isEmpty == false else { - throw WebInspectorProxyError.commandFailed( - domain: command.domain.rawValue, - method: command.method, - message: "No enqueued result for \(command.domain.rawValue).\(command.method)." - ) - } - - let queued = results.removeFirst() - enqueuedReplies[key] = results.isEmpty ? nil : results - - let value: Any - switch queued.storage { - case let .result(result): - value = result - case let .failure(error): - throw error - } - - guard let result = value as? Result else { - throw WebInspectorProxyError.commandFailed( - domain: command.domain.rawValue, - method: command.method, - message: "Enqueued result for \(command.domain.rawValue).\(command.method) has type " - + "\(type(of: value)); expected \(Result.self)." - ) - } - return result - } - - package func waitForEventSubscription( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) async { - await waitForSubscriber(route: route, targetID: targetID, domain: domain, count: 1) - } - - package nonisolated func events( - route: RoutingTargetID, - targetID: WebInspectorTarget.ID, - domain: WebInspectorProxyEventDomain - ) -> AsyncStream { - _ = route - let key = EventSubscriptionKey(route: route, targetID: targetID, domain: domain) - return AsyncStream { continuation in - let id = UUID() - Task { - await self.addEventContinuation(continuation, id: id, key: key) - } - continuation.onTermination = { _ in - Task { - await self.removeEventContinuation(id: id, key: key) - } - } - } - } -} diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestGate.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestGate.swift deleted file mode 100644 index 163f2ca1..00000000 --- a/Sources/WebInspectorProxyKitTesting/WebInspectorTestGate.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -/// Async gate used to hold and release test backend commands. -public actor WebInspectorTestGate { - private var isOpen: Bool - private var waiters: [CheckedContinuation] - - /// Creates a closed gate. - public init() { - isOpen = false - waiters = [] - } - - /// Suspends until the gate is opened. - public func wait() async { - guard isOpen == false else { - return - } - await withCheckedContinuation { continuation in - if isOpen { - continuation.resume() - } else { - waiters.append(continuation) - } - } - } - - /// Opens the gate and resumes all current waiters. - public func open() async { - guard isOpen == false else { - return - } - isOpen = true - let currentWaiters = waiters - waiters.removeAll() - for waiter in currentWaiters { - waiter.resume() - } - } -} diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestJSONObject.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestJSONObject.swift new file mode 100644 index 00000000..2a9d17fb --- /dev/null +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorTestJSONObject.swift @@ -0,0 +1,97 @@ +import Foundation + +/// A validated Web Inspector protocol JSON object. +/// +/// The value always contains a top-level JSON object. Its stored bytes use a +/// canonical sorted-key representation, so equality compares JSON object +/// semantics rather than the caller's whitespace or key ordering. +public struct WebInspectorTestJSONObject: Equatable, Sendable { + /// The empty JSON object. + public static let empty = WebInspectorTestJSONObject( + canonicalData: Data("{}".utf8) + ) + + private let canonicalData: Data + + /// Validates and canonicalizes a JSON object string. + /// + /// - Throws: ``WebInspectorTestPeerError/invalidJSONObject`` when `json` + /// is not valid JSON or its top-level value is not an object. + public init(json: String) throws { + guard let data = json.data(using: .utf8) else { + throw WebInspectorTestPeerError.invalidJSONObject + } + try self.init(data: data) + } + + /// Validates and canonicalizes UTF-8 JSON object data. + /// + /// - Throws: ``WebInspectorTestPeerError/invalidJSONObject`` when `data` + /// is not valid JSON or its top-level value is not an object. + public init(data: Data) throws { + try self.init(validating: data) + } + + /// Encodes a typed fixture and validates that it produces a JSON object. + /// + /// - Throws: ``WebInspectorTestPeerError/invalidJSONObject`` when encoding + /// fails or `value` does not encode as a top-level object. + public init(encoding value: Value) throws { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + throw WebInspectorTestPeerError.invalidJSONObject + } + try self.init(data: data) + } + + /// The canonical sorted-key UTF-8 representation of the object. + public var data: Data { + canonicalData + } + + /// Decodes the object into a test fixture type. + public func decode( + _ type: Value.Type + ) throws -> Value { + try JSONDecoder().decode(type, from: canonicalData) + } + + init(validating data: Data) throws { + let value: Any + do { + value = try JSONSerialization.jsonObject(with: data) + } catch { + throw WebInspectorTestPeerError.invalidJSONObject + } + guard let object = value as? [String: Any] else { + throw WebInspectorTestPeerError.invalidJSONObject + } + try self.init(validatedObject: object) + } + + init(validatedObject object: [String: Any]) throws { + let data: Data + do { + data = try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + } catch { + throw WebInspectorTestPeerError.invalidJSONObject + } + self.init(canonicalData: data) + } + + var utf8String: String { + guard let string = String(data: canonicalData, encoding: .utf8) else { + preconditionFailure("Canonical JSON object bytes must remain UTF-8.") + } + return string + } + + private init(canonicalData: Data) { + self.canonicalData = canonicalData + } +} diff --git a/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift b/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift new file mode 100644 index 00000000..e1273332 --- /dev/null +++ b/Sources/WebInspectorProxyKitTesting/WebInspectorTestPeer.swift @@ -0,0 +1,816 @@ +import CoreFoundation +import Foundation +import Synchronization +import WebInspectorProxyKit + +/// Errors produced by the raw Web Inspector test peer. +public enum WebInspectorTestPeerError: Error, Equatable, Sendable { + /// A JSON fixture was invalid or its top-level value was not an object. + case invalidJSONObject + + /// ProxyKit emitted a malformed protocol command. + case malformedOutboundCommand + + /// The simulated connection is no longer open. + case connectionClosed + + /// The command was created by another peer. + case foreignCommand + + /// The command belonged to this peer before its connection terminated. + case staleCommand + + /// The command has already received its one reply or failure. + case commandAlreadyCompleted +} + +/// A concrete raw-wire WebKit peer for ProxyKit and DataKit tests. +/// +/// The peer implements only the transport boundary. Commands and events still +/// pass through ProxyKit's production connection core, registry, router, JSON +/// codecs, model feed, and authority checks. +public actor WebInspectorTestPeer { + /// One outbound Web Inspector protocol command. + public struct Command: Equatable, Sendable { + /// An opaque command correlation owned by one peer connection. + public struct Correlation: Hashable, Sendable { + fileprivate let peerID: UUID + fileprivate let ordinal: UInt64 + + fileprivate init(peerID: UUID, ordinal: UInt64) { + self.peerID = peerID + self.ordinal = ordinal + } + } + + /// The wire destination selected by ProxyKit. + public enum Destination: Equatable, Sendable { + /// The root inspector connection. + case root + + /// A physical WebKit target identifier. + case target(String) + } + + /// The opaque correlation used by ``WebInspectorTestPeer/reply(to:with:)`` + /// and ``WebInspectorTestPeer/fail(_:message:)``. + public let correlation: Correlation + + /// The root or target wire destination. + public let destination: Destination + + /// The raw Web Inspector protocol method, such as `DOM.getDocument`. + public let method: String + + /// The validated protocol `params` object. + public let parameters: WebInspectorTestJSONObject + + fileprivate init( + correlation: Correlation, + destination: Destination, + method: String, + parameters: WebInspectorTestJSONObject + ) { + self.correlation = correlation + self.destination = destination + self.method = method + self.parameters = parameters + } + } + + /// The peer-owned FIFO channel of commands emitted by ProxyKit. + /// + /// This immutable value owns only the external mailbox, not its peer. A + /// suspended ``next()`` therefore cannot keep a dropped connection owner + /// alive. Peer termination resumes every pending consumer with + /// ``WebInspectorTestPeerError/connectionClosed``. + public struct Commands: Sendable { + fileprivate let mailbox: WebInspectorTestCommandMailbox + + fileprivate init(mailbox: WebInspectorTestCommandMailbox) { + self.mailbox = mailbox + } + + /// Waits for the next outbound command in exact transport FIFO order. + /// + /// Cancellation removes only this waiter and never consumes a future + /// command. + public func next() async throws -> Command { + try await mailbox.next() + } + + func nextForTesting( + afterWaiterAllocation action: @escaping @Sendable () async -> Void + ) async throws -> Command { + try await mailbox.next(afterWaiterAllocation: action) + } + + var pendingWaiterCountForTesting: Int { + mailbox.pendingWaiterCount + } + } + + /// Raw fields for a WebKit `Target.targetCreated` event. + public struct Target: Equatable, Sendable { + /// The default committed main-page target installed by the test runtime. + public static let initialPage = Target( + id: "page-main", + type: "page", + frameID: "main-frame" + ) + + /// The physical target identifier. + public let id: String + + /// The raw WebKit target type. + public let type: String + + /// The physical frame identifier, when present. + public let frameID: String? + + /// The parent physical frame identifier, when present. + public let parentFrameID: String? + + /// The domain names advertised by the target, when WebKit supplies them. + public let domains: [String]? + + /// Whether the target is provisional. + public let isProvisional: Bool + + /// Whether the target begins paused. + public let isPaused: Bool + + /// Creates raw target fields for a target-created wire event. + public init( + id: String, + type: String, + frameID: String? = nil, + parentFrameID: String? = nil, + domains: [String]? = nil, + isProvisional: Bool = false, + isPaused: Bool = false + ) { + self.id = id + self.type = type + self.frameID = frameID + self.parentFrameID = parentFrameID + self.domains = domains + self.isProvisional = isProvisional + self.isPaused = isPaused + } + } + + private enum State { + case unattached + case open + case closed + } + + private enum ReplyRoute: Sendable { + case root(commandID: UInt64) + case target(targetID: String, innerCommandID: UInt64, outerCommandID: UInt64) + } + + private struct DecodedCommand: Sendable { + let destination: Command.Destination + let method: String + let parameters: WebInspectorTestJSONObject + let replyRoute: ReplyRoute + } + + private let peerID: UUID + private nonisolated let commandMailbox: WebInspectorTestCommandMailbox + /// Commands emitted by ProxyKit through this peer's transport endpoint. + public nonisolated let commands: Commands + private weak var core: ConnectionCore? + private var receiver: TransportReceiver? + private var state: State + private var nextCommandOrdinal: UInt64 + private var outstandingCommands: [UInt64: ReplyRoute] + private var postDrainActionForTesting: (@Sendable () async -> Void)? + + init() { + let commandMailbox = WebInspectorTestCommandMailbox() + peerID = UUID() + self.commandMailbox = commandMailbox + commands = Commands(mailbox: commandMailbox) + core = nil + receiver = nil + state = .unattached + nextCommandOrdinal = 0 + outstandingCommands = [:] + postDrainActionForTesting = nil + } + + isolated deinit { + commandMailbox.finish() + receiver?.closeSynchronously() + outstandingCommands.removeAll(keepingCapacity: false) + } + + /// Sends one successful protocol reply for `command`. + /// + /// A targeted command produces the same two inbound messages as WebKit: an + /// outer `Target.sendMessageToTarget` acknowledgement followed by the inner + /// target reply. Reusing a command throws + /// ``WebInspectorTestPeerError/commandAlreadyCompleted``. + public func reply( + to command: Command, + with result: WebInspectorTestJSONObject = .empty + ) async throws { + let route = try await takeReplyRoute(for: command) + do { + try await receive(replyMessages(for: route, result: result, errorMessage: nil)) + } catch WebInspectorTestPeerError.connectionClosed { + throw WebInspectorTestPeerError.staleCommand + } + } + + /// Sends one failing protocol reply for `command`. + public func fail(_ command: Command, message: String) async throws { + let route = try await takeReplyRoute(for: command) + do { + try await receive(replyMessages(for: route, result: nil, errorMessage: message)) + } catch WebInspectorTestPeerError.connectionClosed { + throw WebInspectorTestPeerError.staleCommand + } + } + + /// Emits a raw `Target.targetCreated` event and waits until Core applies it. + public func createTarget(_ target: Target) async throws { + let parameters = try WebInspectorTestJSONObject(encoding: + TargetCreatedParameters(targetInfo: .init(target)) + ) + try await emitRootEvent(method: "Target.targetCreated", parameters: parameters) + } + + /// Emits a raw `Target.didCommitProvisionalTarget` event and waits until + /// Core applies the retarget mutation. + public func commitProvisionalTarget( + from oldTargetID: String, + to newTargetID: String + ) async throws { + let parameters = try WebInspectorTestJSONObject(encoding: + TargetCommitParameters(oldTargetId: oldTargetID, newTargetId: newTargetID) + ) + try await emitRootEvent( + method: "Target.didCommitProvisionalTarget", + parameters: parameters + ) + } + + /// Emits a raw `Target.targetDestroyed` event and waits until Core applies it. + public func destroyTarget(id: String) async throws { + let parameters = try WebInspectorTestJSONObject(encoding: + TargetDestroyedParameters(targetId: id) + ) + try await emitRootEvent(method: "Target.targetDestroyed", parameters: parameters) + } + + /// Emits one root protocol event and waits for Core's accepted-message + /// barrier. Event sequence and domain watermarks are generated only by Core. + public func emitRootEvent( + method: String, + parameters: WebInspectorTestJSONObject = .empty + ) async throws { + try await receive([ + try Self.eventMessage(method: method, parameters: parameters), + ]) + } + + /// Emits one target protocol event and waits for Core's accepted-message + /// barrier. The peer supplies only raw WebKit wire input. + public func emitTargetEvent( + targetID: String, + method: String, + parameters: WebInspectorTestJSONObject = .empty + ) async throws { + let innerMessage = try Self.eventMessage(method: method, parameters: parameters) + try await receive([ + try Self.targetDispatchMessage(targetID: targetID, message: innerMessage), + ]) + } + + /// Simulates a clean remote EOF and waits for the connection to close. + /// + /// Normal test ownership ends with ``WebInspectorProxyTestRuntime/close()``. + /// Use this method only when clean peer-initiated termination is the input + /// under test. + public func closeConnection() async { + guard let core else { + finishConnection() + return + } + await core.close() + } + + /// Simulates a fatal transport failure and waits for terminal completion. + public func failConnection(with message: String) async { + guard case .open = state, let receiver, let core else { + return + } + let handoff = receiver.fail(message) + await handoff?.value + _ = try? await core.waitUntilClosed() + } + + fileprivate func attach(core: ConnectionCore, receiver: TransportReceiver) { + precondition( + state == .unattached, + "A WebInspectorTestPeer can attach to only one connection." + ) + self.core = core + self.receiver = receiver + state = .open + } + + fileprivate func acceptOutboundMessage(_ message: String) throws { + guard case .open = state else { + throw WebInspectorTestPeerError.connectionClosed + } + let decoded = try Self.decodeOutboundMessage(message) + precondition( + nextCommandOrdinal < UInt64.max, + "WebInspectorTestPeer exhausted its command correlation space." + ) + nextCommandOrdinal += 1 + let correlation = Command.Correlation( + peerID: peerID, + ordinal: nextCommandOrdinal + ) + let command = Command( + correlation: correlation, + destination: decoded.destination, + method: decoded.method, + parameters: decoded.parameters + ) + precondition( + outstandingCommands[nextCommandOrdinal] == nil, + "A WebInspectorTestPeer correlation was reused." + ) + outstandingCommands[nextCommandOrdinal] = decoded.replyRoute + commandMailbox.append(command) + } + + fileprivate func transportDidDetach() async { + let attachedReceiver = receiver + finishConnection() + await attachedReceiver?.close() + receiver = nil + core = nil + } + + func setPostDrainActionForTesting( + _ action: (@Sendable () async -> Void)? + ) { + postDrainActionForTesting = action + } + + private func takeReplyRoute(for command: Command) async throws -> ReplyRoute { + guard command.correlation.peerID == peerID, + command.correlation.ordinal <= nextCommandOrdinal else { + throw WebInspectorTestPeerError.foreignCommand + } + guard case .open = state else { + throw WebInspectorTestPeerError.staleCommand + } + guard outstandingCommands[command.correlation.ordinal] != nil else { + throw WebInspectorTestPeerError.commandAlreadyCompleted + } + do { + _ = try await requireCoreAdmission() + } catch WebInspectorTestPeerError.connectionClosed { + throw WebInspectorTestPeerError.staleCommand + } + guard case .open = state else { + throw WebInspectorTestPeerError.staleCommand + } + guard let route = outstandingCommands.removeValue( + forKey: command.correlation.ordinal + ) else { + throw WebInspectorTestPeerError.commandAlreadyCompleted + } + return route + } + + private func receive(_ messages: [String]) async throws { + let admittedCore = try await requireCoreAdmission() + guard case .open = state, + let receiver, + core === admittedCore else { + throw WebInspectorTestPeerError.connectionClosed + } + precondition(messages.isEmpty == false, "A test peer receive must contain at least one message.") + var through: UInt64? + for message in messages { + guard let acceptedOrdinal = receiver.receive(message) else { + finishConnection() + throw WebInspectorTestPeerError.connectionClosed + } + through = acceptedOrdinal + } + guard let through else { + preconditionFailure("A non-empty test peer receive produced no watermark.") + } + await receiver.waitUntilDrained(through: through) + let postDrainAction = postDrainActionForTesting + postDrainActionForTesting = nil + await postDrainAction?() + do { + try await admittedCore.requireOpen() + guard case .open = state, core === admittedCore else { + throw WebInspectorTestPeerError.connectionClosed + } + } catch { + let terminalCause = await admittedCore.terminalCause + if terminalCause == .explicitClose { + // Explicit owner close may begin immediately after the reply + // fulfills its command. Wait for an active receiver drain to + // become quiescent before deciding whether this input was + // completed or discarded by close. + await receiver.close() + if receiver.hasCompletedDrain(through: through) { + finishConnection() + return + } + } + finishConnection() + throw WebInspectorTestPeerError.connectionClosed + } + } + + private func requireCoreAdmission() async throws -> ConnectionCore { + guard case .open = state, let core else { + throw WebInspectorTestPeerError.connectionClosed + } + do { + try await core.requireOpen() + } catch { + finishConnection() + throw WebInspectorTestPeerError.connectionClosed + } + guard case .open = state, self.core === core else { + throw WebInspectorTestPeerError.connectionClosed + } + return core + } + + private func replyMessages( + for route: ReplyRoute, + result: WebInspectorTestJSONObject?, + errorMessage: String? + ) throws -> [String] { + switch route { + case let .root(commandID): + return [ + try Self.replyMessage( + commandID: commandID, + result: result, + errorMessage: errorMessage + ), + ] + case let .target(targetID, innerCommandID, outerCommandID): + let outerAcknowledgement = try Self.replyMessage( + commandID: outerCommandID, + result: .empty, + errorMessage: nil + ) + let innerReply = try Self.replyMessage( + commandID: innerCommandID, + result: result, + errorMessage: errorMessage + ) + return [ + outerAcknowledgement, + try Self.targetDispatchMessage( + targetID: targetID, + message: innerReply + ), + ] + } + } + + private func finishConnection() { + guard state != .closed else { + return + } + state = .closed + outstandingCommands.removeAll(keepingCapacity: false) + commandMailbox.finish() + } + + private static func decodeOutboundMessage(_ message: String) throws -> DecodedCommand { + guard let data = message.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let outerID = commandID(in: object), + let method = object["method"] as? String else { + throw WebInspectorTestPeerError.malformedOutboundCommand + } + + let parameters = try parametersObject(in: object) + guard method == "Target.sendMessageToTarget" else { + return DecodedCommand( + destination: .root, + method: method, + parameters: parameters, + replyRoute: .root(commandID: outerID) + ) + } + + guard let wrapper = object["params"] as? [String: Any], + let targetID = wrapper["targetId"] as? String, + let innerMessage = wrapper["message"] as? String, + let innerData = innerMessage.data(using: .utf8), + let innerObject = try? JSONSerialization.jsonObject(with: innerData) as? [String: Any], + let innerID = commandID(in: innerObject), + let innerMethod = innerObject["method"] as? String, + innerMethod != "Target.sendMessageToTarget" else { + throw WebInspectorTestPeerError.malformedOutboundCommand + } + + return DecodedCommand( + destination: .target(targetID), + method: innerMethod, + parameters: try parametersObject(in: innerObject), + replyRoute: .target( + targetID: targetID, + innerCommandID: innerID, + outerCommandID: outerID + ) + ) + } + + private static func commandID(in object: [String: Any]) -> UInt64? { + guard let number = object["id"] as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let value = number.doubleValue + guard value.isFinite, + value >= 0, + value.rounded(.towardZero) == value, + value <= Double(UInt64.max) else { + return nil + } + return number.uint64Value + } + + private static func parametersObject( + in command: [String: Any] + ) throws -> WebInspectorTestJSONObject { + guard let parameters = command["params"] else { + return .empty + } + guard let object = parameters as? [String: Any] else { + throw WebInspectorTestPeerError.malformedOutboundCommand + } + return try WebInspectorTestJSONObject(validatedObject: object) + } + + private static func eventMessage( + method: String, + parameters: WebInspectorTestJSONObject + ) throws -> String { + "{\"method\":\(try jsonStringLiteral(method)),\"params\":\(parameters.utf8String)}" + } + + private static func replyMessage( + commandID: UInt64, + result: WebInspectorTestJSONObject?, + errorMessage: String? + ) throws -> String { + if let errorMessage { + return "{\"id\":\(commandID),\"error\":{\"message\":\(try jsonStringLiteral(errorMessage))}}" + } + guard let result else { + preconditionFailure("A successful test reply requires a result object.") + } + return "{\"id\":\(commandID),\"result\":\(result.utf8String)}" + } + + private static func targetDispatchMessage( + targetID: String, + message: String + ) throws -> String { + "{\"method\":\"Target.dispatchMessageFromTarget\",\"params\":{" + + "\"targetId\":\(try jsonStringLiteral(targetID))," + + "\"message\":\(try jsonStringLiteral(message))}}" + } + + private static func jsonStringLiteral(_ value: String) throws -> String { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + throw WebInspectorTestPeerError.malformedOutboundCommand + } + guard let string = String(data: data, encoding: .utf8) else { + throw WebInspectorTestPeerError.malformedOutboundCommand + } + return string + } +} + +private final class WebInspectorTestPeerTransportBackend: TransportBackend { + private let peer: WebInspectorTestPeer + + init(peer: WebInspectorTestPeer) { + self.peer = peer + } + + func sendJSONString(_ message: String) async throws { + try await peer.acceptOutboundMessage(message) + } + + func detach() async { + await peer.transportDidDetach() + } +} + +private final class WebInspectorTestCommandMailbox: Sendable { + private struct Waiter: Sendable { + let id: UInt64 + let continuation: CheckedContinuation + } + + private struct State: Sendable { + var commands: [WebInspectorTestPeer.Command] = [] + var commandStartIndex = 0 + var waiters: [Waiter] = [] + var nextWaiterID: UInt64 = 0 + var registeringWaiterIDs: Set = [] + var isFinished = false + } + + private enum RegistrationAction { + case wait + case command(WebInspectorTestPeer.Command) + case cancelled + case finished + } + + private let state = Mutex(State()) + + var pendingWaiterCount: Int { + state.withLock { $0.waiters.count } + } + + func append(_ command: WebInspectorTestPeer.Command) { + let waiter = state.withLock { state -> Waiter? in + guard !state.isFinished else { + return nil + } + if !state.waiters.isEmpty { + return state.waiters.removeFirst() + } + state.commands.append(command) + return nil + } + waiter?.continuation.resume(returning: command) + } + + func next( + afterWaiterAllocation: (@Sendable () async -> Void)? = nil + ) async throws -> WebInspectorTestPeer.Command { + try Task.checkCancellation() + let waiterID = state.withLock { state -> UInt64 in + precondition( + state.nextWaiterID < UInt64.max, + "WebInspectorTestPeer exhausted its command waiter space." + ) + state.nextWaiterID += 1 + let waiterID = state.nextWaiterID + state.registeringWaiterIDs.insert(waiterID) + return waiterID + } + return try await withTaskCancellationHandler { + await afterWaiterAllocation?() + return try await withCheckedThrowingContinuation { continuation in + let action = register(waiterID, continuation: continuation) + switch action { + case .wait: + break + case let .command(command): + continuation.resume(returning: command) + case .cancelled: + continuation.resume(throwing: CancellationError()) + case .finished: + continuation.resume(throwing: WebInspectorTestPeerError.connectionClosed) + } + } + } onCancel: { + cancel(waiterID) + } + } + + func finish() { + let waiters = state.withLock { state -> [Waiter] in + guard !state.isFinished else { + return [] + } + state.isFinished = true + state.commands.removeAll(keepingCapacity: false) + state.commandStartIndex = 0 + let waiters = state.waiters + state.waiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.continuation.resume(throwing: WebInspectorTestPeerError.connectionClosed) + } + } + + private func register( + _ waiterID: UInt64, + continuation: CheckedContinuation + ) -> RegistrationAction { + state.withLock { state in + guard state.registeringWaiterIDs.remove(waiterID) != nil else { + return .cancelled + } + if state.isFinished { + return .finished + } + if state.commandStartIndex < state.commands.count { + let command = state.commands[state.commandStartIndex] + state.commandStartIndex += 1 + compactCommandsIfNeeded(in: &state) + return .command(command) + } + state.waiters.append(Waiter(id: waiterID, continuation: continuation)) + return .wait + } + } + + private func cancel(_ waiterID: UInt64) { + let waiter = state.withLock { state -> Waiter? in + guard !state.isFinished else { + return nil + } + guard let index = state.waiters.firstIndex(where: { $0.id == waiterID }) else { + state.registeringWaiterIDs.remove(waiterID) + return nil + } + return state.waiters.remove(at: index) + } + waiter?.continuation.resume(throwing: CancellationError()) + } + + private func compactCommandsIfNeeded(in state: inout State) { + guard state.commandStartIndex > 64, + state.commandStartIndex * 2 >= state.commands.count else { + return + } + state.commands.removeFirst(state.commandStartIndex) + state.commandStartIndex = 0 + } +} + +private struct TargetCreatedParameters: Encodable { + let targetInfo: TargetInfo + + struct TargetInfo: Encodable { + let targetId: String + let type: String + let frameId: String? + let parentFrameId: String? + let domains: [String]? + let isProvisional: Bool + let isPaused: Bool + + init(_ target: WebInspectorTestPeer.Target) { + targetId = target.id + type = target.type + frameId = target.frameID + parentFrameId = target.parentFrameID + domains = target.domains + isProvisional = target.isProvisional + isPaused = target.isPaused + } + } +} + +private struct TargetCommitParameters: Encodable { + let oldTargetId: String + let newTargetId: String +} + +private struct TargetDestroyedParameters: Encodable { + let targetId: String +} + +extension WebInspectorTestPeer { + func makeConnection( + configuration: WebInspectorProxy.Configuration + ) async -> ConnectionCore { + let receiver = TransportReceiver() + let backend = WebInspectorTestPeerTransportBackend(peer: self) + let core = ConnectionCore( + backend: backend, + responseTimeout: configuration.responseTimeout + ) + receiver.setCore(core) + attach(core: core, receiver: receiver) + return core + } +} diff --git a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift index b6524c76..90ab74aa 100644 --- a/Sources/WebInspectorUI/Containers/CompactTabBarController.swift +++ b/Sources/WebInspectorUI/Containers/CompactTabBarController.swift @@ -6,14 +6,18 @@ import WebInspectorUIBase @MainActor package final class CompactTabBarController: UITabBarController, UITabBarControllerDelegate { private let session: WebInspectorSession + private let contentStore: PresentationContentStore private let tabTransitionAnimator = NoAnimationTabTransitionAnimator() private var nativeTabByItemID: [WebInspectorTab.DisplayItem.ID: UITab] = [:] - private var renderedContentRevision: Int? private var interfaceObservation: PortableObservationTracking.Token? private var isRenderingSelection = false - package init(session: WebInspectorSession) { + package init( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) { self.session = session + self.contentStore = contentStore super.init(nibName: nil, bundle: nil) delegate = self @@ -78,25 +82,18 @@ package final class CompactTabBarController: UITabBarController, UITabBarControl private func renderInterface(_ interface: InterfaceModel, animated: Bool) { let displayItems = interface.displayItems(for: .compact) let selectedDisplayItem = interface.resolvedSelection(for: .compact) - let contentRevision = interface.contextBoundContentRevision - let shouldRebuildContent = renderedContentRevision.map { $0 != contentRevision } ?? false renderSelectionFromInterface { - if shouldRebuildContent { - nativeTabByItemID.removeAll() - } - setTabsIfNeeded(for: displayItems, animated: animated, force: shouldRebuildContent) + setTabsIfNeeded(for: displayItems, animated: animated) renderSelection(selectedDisplayItem) - renderedContentRevision = contentRevision } } private func setTabsIfNeeded( for displayItems: [WebInspectorTab.DisplayItem], - animated: Bool, - force: Bool = false + animated: Bool ) { let nextItemIDs = displayItems.map(\.id) - guard force || tabs.map(\.identifier) != nextItemIDs else { + guard tabs.map(\.identifier) != nextItemIDs else { return } setTabs(nativeTabs(for: displayItems), animated: animated) @@ -133,6 +130,7 @@ package final class CompactTabBarController: UITabBarController, UITabBarControl let descriptor = session.interface.descriptor(for: displayItem) let session = session + let contentStore = contentStore let nativeTab = UITab( title: descriptor?.title ?? "", image: descriptor?.image, @@ -144,8 +142,8 @@ package final class CompactTabBarController: UITabBarController, UITabBarControl WebInspectorTab.ContentFactory.makeViewController( for: displayItem, session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) } nativeTabByItemID[itemID] = nativeTab diff --git a/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift b/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift index 8d4e73f5..795b6e3e 100644 --- a/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift +++ b/Sources/WebInspectorUI/Containers/RegularTabContentViewController.swift @@ -6,9 +6,9 @@ import WebInspectorUIBase @MainActor package final class RegularTabContentViewController: UINavigationController { private let session: WebInspectorSession + private let contentStore: PresentationContentStore private var segmentDisplayItemIDs: [WebInspectorTab.DisplayItem.ID] = [] private var displayedDisplayItemID: WebInspectorTab.DisplayItem.ID? - private var renderedContentRevision: Int? private var interfaceObservation: PortableObservationTracking.Token? private lazy var segmentBarButtonItem: UIBarButtonItem = { @@ -28,8 +28,12 @@ package final class RegularTabContentViewController: UINavigationController { return control }() - package init(session: WebInspectorSession) { + package init( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) { self.session = session + self.contentStore = contentStore super.init(nibName: nil, bundle: nil) navigationBar.prefersLargeTitles = false @@ -88,16 +92,13 @@ package final class RegularTabContentViewController: UINavigationController { private func renderInterface(_ interface: InterfaceModel) { let displayItems = interface.displayItems(for: .regular) let selectedDisplayItem = interface.resolvedSelection(for: .regular) - let contentRevision = interface.contextBoundContentRevision - let shouldRebuildContent = renderedContentRevision.map { $0 != contentRevision } ?? false let activeItemIDs = Set(displayItems.map(\.id)) if let displayedDisplayItemID, activeItemIDs.contains(displayedDisplayItemID) == false { self.displayedDisplayItemID = nil } setSegments(for: displayItems) - renderSelection(selectedDisplayItem, forceContentReplacement: shouldRebuildContent) - renderedContentRevision = contentRevision + renderSelection(selectedDisplayItem) } private func setSegments(for displayItems: [WebInspectorTab.DisplayItem]) { @@ -118,8 +119,7 @@ package final class RegularTabContentViewController: UINavigationController { } private func renderSelection( - _ selectedDisplayItem: WebInspectorTab.DisplayItem?, - forceContentReplacement: Bool = false + _ selectedDisplayItem: WebInspectorTab.DisplayItem? ) { let selectedSegmentIndex = selectedDisplayItem.flatMap { segmentDisplayItemIDs.firstIndex(of: $0.id) @@ -128,14 +128,14 @@ package final class RegularTabContentViewController: UINavigationController { segmentedControl.selectedSegmentIndex = selectedSegmentIndex } guard let selectedDisplayItem else { - guard forceContentReplacement || displayedDisplayItemID != nil || viewControllers.isEmpty == false else { + guard displayedDisplayItemID != nil || viewControllers.isEmpty == false else { return } displayedDisplayItemID = nil setViewControllers([], animated: false) return } - guard forceContentReplacement || displayedDisplayItemID != selectedDisplayItem.id || viewControllers.isEmpty else { + guard displayedDisplayItemID != selectedDisplayItem.id || viewControllers.isEmpty else { return } @@ -152,8 +152,8 @@ package final class RegularTabContentViewController: UINavigationController { let viewController = WebInspectorTab.ContentFactory.makeViewController( for: displayItem, session: session, - hostLayout: .regular, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .regular ) viewController.navigationItem.style = .browser viewController.navigationItem.centerItemGroups = [segmentItemGroup] diff --git a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift index 8a82a1f5..c69ad11a 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorSession.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorSession.swift @@ -3,23 +3,23 @@ import Observation import UIKit import WebKit import WebInspectorDataKit +import WebInspectorProxyKit import WebInspectorUIBase -import WebInspectorUINetwork /// The UIKit-facing inspection session used by `WebInspectorViewController`. /// -/// A session owns attachment lifecycle, the current DataKit context, tab -/// selection state, and page-derived presentation preferences. +/// A session owns attachment lifecycle, one stable DataKit model, and +/// page-derived presentation preferences. @MainActor @Observable public final class WebInspectorSession { package let interface: InterfaceModel + /// The stable semantic model used by built-in and custom tabs. + @ObservationIgnored public let model: WebInspectorModelContext /// The user interface style inferred from the inspected page. /// /// The value is `.unspecified` until the page style is known or when no useful style can be inferred. public private(set) var pageUserInterfaceStyle: UIUserInterfaceStyle = .unspecified - @ObservationIgnored private var container: WebInspectorContainer? - @ObservationIgnored private var dataContext: WebInspectorContext @ObservationIgnored private var attachmentGeneration: UInt64 = 0 @ObservationIgnored private let makePageUserInterfaceStyleObserver: @MainActor ( WKWebView, @@ -31,16 +31,23 @@ public final class WebInspectorSession { #endif /// Creates a session with the provided inspector tabs. - public init(tabs: [WebInspectorTab] = [.dom, .network]) { + public init( + tabs: [WebInspectorTab] = [.dom, .network], + additionalDomains: Set = [] + ) { self.interface = InterfaceModel(tabs: tabs) - self.dataContext = Self.makeDetachedDataContext() + self.model = WebInspectorModelContext(configuration: .init( + domains: tabs.reduce(into: additionalDomains) { domains, tab in + domains.formUnion(tab.requiredDomains) + } + )) self.makePageUserInterfaceStyleObserver = { webView, apply in WebInspectorPageUserInterfaceStyleObserver(webView: webView, apply: apply) } } package init( - context: WebInspectorContext, + context: WebInspectorModelContext, tabs: [WebInspectorTab] = [.dom, .network], makePageUserInterfaceStyleObserver: @escaping @MainActor ( WKWebView, @@ -50,27 +57,22 @@ public final class WebInspectorSession { } ) { self.interface = InterfaceModel(tabs: tabs) - self.dataContext = context + self.model = context self.makePageUserInterfaceStyleObserver = makePageUserInterfaceStyleObserver } isolated deinit { stopPageUserInterfaceStyleObservation() - interface.removeContentCache() - } - - package var context: WebInspectorContext { - dataContext } /// Attaches the session to a web view. /// - /// Attaching replaces any previous inspection context owned by this - /// session. + /// Reattachment preserves model and result identity while replacing the + /// exclusively owned ProxyKit connection. public func attach(to webView: WKWebView) async throws { try await attach( - makeContainer: { - try await WebInspectorContainer(attachingTo: webView) + makeProxy: { + try await WebInspectorProxy(attachingTo: webView) }, makePageUserInterfaceStyleObserver: { [makePageUserInterfaceStyleObserver] apply in makePageUserInterfaceStyleObserver(webView, apply) @@ -80,11 +82,11 @@ public final class WebInspectorSession { package func attach( to webView: WKWebView, - makeContainer: @MainActor (WKWebView) async throws -> WebInspectorContainer + makeProxy: @MainActor (WKWebView) async throws -> WebInspectorProxy ) async throws { try await attach( - makeContainer: { - try await makeContainer(webView) + makeProxy: { + try await makeProxy(webView) }, makePageUserInterfaceStyleObserver: { [makePageUserInterfaceStyleObserver] apply in makePageUserInterfaceStyleObserver(webView, apply) @@ -93,89 +95,114 @@ public final class WebInspectorSession { } package func attachForTesting( - makeContainer: @escaping @MainActor () async throws -> WebInspectorContainer, + makeProxy: @escaping @MainActor () async throws -> WebInspectorProxy, makePageUserInterfaceStyleObserver: @escaping @MainActor ( @escaping @MainActor (UIUserInterfaceStyle) -> Void - ) -> (any WebInspectorPageUserInterfaceStyleObserving)? = { _ in nil } + ) -> (any WebInspectorPageUserInterfaceStyleObserving)? = { _ in nil }, + afterModelAttach: (@MainActor () async -> Void)? = nil ) async throws { try await attach( - makeContainer: makeContainer, - makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver + makeProxy: makeProxy, + makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver, + afterModelAttach: afterModelAttach ) } private func attach( - makeContainer: @MainActor () async throws -> WebInspectorContainer, + makeProxy: @MainActor () async throws -> WebInspectorProxy, makePageUserInterfaceStyleObserver: @MainActor ( @escaping @MainActor (UIUserInterfaceStyle) -> Void - ) -> (any WebInspectorPageUserInterfaceStyleObserving)? + ) -> (any WebInspectorPageUserInterfaceStyleObserving)?, + afterModelAttach: (@MainActor () async -> Void)? = nil ) async throws { let generation = advanceAttachmentGeneration() stopPageUserInterfaceStyleObservation() - await stopContainer(replaceContextWithDetached: false) try Task.checkCancellation() guard isCurrentAttachmentGeneration(generation) else { throw CancellationError() } do { - let container = try await makeContainer() + let proxy = try await makeProxy() try Task.checkCancellation() guard isCurrentAttachmentGeneration(generation) else { - await container.close() + await proxy.close() + throw CancellationError() + } + do { + try await model.attach(to: proxy, isolation: MainActor.shared) + } catch { + await proxy.close() + throw error + } + if let afterModelAttach { + await afterModelAttach() + } + guard isCurrentAttachmentGeneration(generation) else { + await model.detachIfAttached(to: proxy) throw CancellationError() } - self.container = container - installDataContext(container.mainContext) startPageUserInterfaceStyleObservation(makePageUserInterfaceStyleObserver) } catch { guard isCurrentAttachmentGeneration(generation) else { throw error } - installDataContext(Self.makeDetachedDataContext()) stopPageUserInterfaceStyleObservation() throw error } } - /// Detaches the session and replaces the current context with a detached - /// placeholder context. + /// Detaches the session while preserving its model identity for reuse. public func detach() async { - await detachAndReplaceContext() + await detachModel() } - private func detachAndReplaceContext() async { + private func detachModel() async { advanceAttachmentGeneration() #if DEBUG detachCountForTesting += 1 #endif stopPageUserInterfaceStyleObservation() - await stopContainer(replaceContextWithDetached: true) + await model.detach() } - package func retireRootPresentation(detach: Bool) async { - guard detach else { - interface.removeContentCache() - await suspendBackendInteractionForPresentationEnd() - return - } - await detachAndReplaceContext() + /// Permanently closes this session and its model connection. + public func close() async { + advanceAttachmentGeneration() + stopPageUserInterfaceStyleObservation() + await model.close() } - /// Mirrors the legacy presentation-end retirement: without tearing down the - /// connection, disable the element picker and hide any visible highlight so - /// a re-presentation starts from a clean interaction state. - private func suspendBackendInteractionForPresentationEnd() async { - // Bind the context once: a concurrent attach can swap dataContext - // across the awaits below, and this retirement must not touch the - // replacement context. - let context = dataContext - guard context.status.state == .attached else { + /// Disables transient page interaction without tearing down the connection. + package func suspendBackendInteraction() async throws { + guard model.state == .attached else { return } - if context.isElementPickerEnabled { - try? await context.setElementPickerEnabled(false) + guard model.configuredDomains.contains(.dom) else { + return + } + + var pickerError: (any Error)? + if try model.isElementPickerEnabled { + do { + try await model.setElementPickerEnabled(false) + } catch { + pickerError = error + } + } + do { + try await model.hideDOMHighlight() + } catch { + if let pickerError { + throw WebInspectorScopeError( + operationError: pickerError, + cleanupError: error + ) + } + throw error + } + if let pickerError { + throw pickerError } - try? await context.hideHighlight() } private func startPageUserInterfaceStyleObservation( @@ -215,31 +242,6 @@ public final class WebInspectorSession { attachmentGeneration == generation } - package func installDataContext(_ context: WebInspectorContext) { - dataContext = context - removeContextBoundContent() - } - - private func stopContainer(replaceContextWithDetached: Bool) async { - removeContextBoundContent() - if let container { - self.container = nil - await container.close() - } else { - await dataContext.stop() - } - if replaceContextWithDetached { - installDataContext(Self.makeDetachedDataContext()) - } - } - - private func removeContextBoundContent() { - interface.removeContextBoundContent() - } - - private static func makeDetachedDataContext() -> WebInspectorContext { - WebInspectorContext.detached(isolation: MainActor.shared) - } } #if DEBUG @@ -253,12 +255,9 @@ extension WebInspectorSession { @MainActor @Observable package final class InterfaceModel { - package private(set) var tabs: [WebInspectorTab] + package let tabs: [WebInspectorTab] package private(set) var selectedItemID: WebInspectorTab.DisplayItem.ID? - package private(set) var contextBoundContentRevision = 0 @ObservationIgnored private let projection = WebInspectorTab.DisplayProjection() - @ObservationIgnored private let contentCache = WebInspectorTab.ContentCache() - @ObservationIgnored private var networkPanelModel: NetworkPanelModel? package init(tabs: [WebInspectorTab] = [.dom, .network]) { let uniqueTabs = Self.uniqueTabs(tabs) @@ -312,59 +311,6 @@ package final class InterfaceModel { selectedItemID = displayItemID } - package func setTabs(_ tabs: [WebInspectorTab]) { - let uniqueTabs = Self.uniqueTabs(tabs) - self.tabs = uniqueTabs - pruneContentCache(retaining: reachableContentKeys(for: uniqueTabs)) - guard let selectedItemID, - isValidItemID(selectedItemID) else { - self.selectedItemID = uniqueTabs.first.map { Self.displayItem(for: $0).id } - return - } - } - - package func viewController( - for key: WebInspectorTab.ContentKey, - make: () -> Content - ) -> Content { - contentCache.viewController(for: key, epoch: contextBoundContentRevision, make: make) - } - - package func networkPanelModel(for context: WebInspectorContext) -> NetworkPanelModel { - if let networkPanelModel, - networkPanelModel.context === context { - return networkPanelModel - } - - let model = NetworkPanelModel(context: context) - networkPanelModel = model - return model - } - - package func removeNetworkPanelModel() { - networkPanelModel = nil - } - - package func removeContextBoundContent() { - removeNetworkPanelModel() - removeContentCache() - contextBoundContentRevision &+= 1 - } - - package func pruneContentCache(retaining keys: Set) { - contentCache.prune(retaining: keys) - } - - package func removeContentCache() { - contentCache.removeAll() - } - - #if DEBUG - package var contentCacheCountForTesting: Int { - contentCache.countForTesting - } - #endif - package var selectedTab: WebInspectorTab? { guard let selectedItemID else { return nil @@ -403,11 +349,6 @@ package final class InterfaceModel { return .customTab(tab.id) } - private func reachableContentKeys(for tabs: [WebInspectorTab]) -> Set { - projection.contentKeys(for: .compact, tabs: tabs) - .union(projection.contentKeys(for: .regular, tabs: tabs)) - } - private static func uniqueTabs(_ tabs: [WebInspectorTab]) -> [WebInspectorTab] { tabs.reduce(into: []) { result, tab in guard result.contains(where: { $0.id == tab.id }) == false else { diff --git a/Sources/WebInspectorUI/Containers/WebInspectorViewController+Preview.swift b/Sources/WebInspectorUI/Containers/WebInspectorViewController+Preview.swift index 9a037e42..5f5cf262 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorViewController+Preview.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorViewController+Preview.swift @@ -7,7 +7,7 @@ import WebInspectorUINetwork @MainActor enum WebInspectorViewControllerPreviewFixtures { static func makeSession() -> WebInspectorSession { - let dataContext = DOMPreviewFixtures.makeWebInspectorContext() + let dataContext = DOMPreviewFixtures.makeWebInspectorModelContext() NetworkPreviewFixtures.applySampleData(to: dataContext, mode: .detail) return WebInspectorSession(context: dataContext) } diff --git a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift index 2fd12f66..e4d08f74 100644 --- a/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift +++ b/Sources/WebInspectorUI/Containers/WebInspectorViewController.swift @@ -1,8 +1,14 @@ #if canImport(UIKit) +import OSLog import UIKit import WebKit import WebInspectorUIBase +private let lifecycleLogger = Logger( + subsystem: "com.lynnswap.WebInspectorKit", + category: "WebInspectorUI.Lifecycle" +) + @MainActor private final class WebInspectorRootPresentationLifecycleCoordinator { #if DEBUG @@ -134,6 +140,7 @@ public final class WebInspectorViewController: UIViewController { /// The inspection session backing the view controller. public let session: WebInspectorSession + private let presentationContentStore: PresentationContentStore /// A Boolean value indicating whether the controller detaches its session /// after the root presentation ends. @@ -170,6 +177,7 @@ public final class WebInspectorViewController: UIViewController { /// Creates a view controller backed by an inspection session. public init(session: WebInspectorSession = WebInspectorSession()) { self.session = session + self.presentationContentStore = PresentationContentStore() super.init(nibName: nil, bundle: nil) webInspectorSetDrawsBackgroundTraitOverride(drawsBackgroundStorage) } @@ -285,7 +293,12 @@ public final class WebInspectorViewController: UIViewController { } private func finishRootPresentationLifecycle() { - presentationLifecycleCoordinator.finishIfNeeded { [session, automaticallyDetachesOnDismiss, presentationLifecycleCoordinator] generation in + presentationLifecycleCoordinator.finishIfNeeded { [ + session, + presentationContentStore, + automaticallyDetachesOnDismiss, + presentationLifecycleCoordinator, + ] generation in removeActiveHost() Task { @MainActor in defer { @@ -299,7 +312,23 @@ public final class WebInspectorViewController: UIViewController { guard presentationLifecycleCoordinator.isCurrentPresentation(generation) else { return } - await session.retireRootPresentation(detach: automaticallyDetachesOnDismiss) + await presentationContentStore.clear() + // Resource retirement may suspend long enough for this root to + // begin a new presentation. Never detach that newer lifetime. + guard presentationLifecycleCoordinator.isCurrentPresentation(generation) else { + return + } + if automaticallyDetachesOnDismiss { + await session.detach() + } else { + do { + try await session.suspendBackendInteraction() + } catch { + lifecycleLogger.error( + "Root presentation cleanup failed: \(String(describing: error), privacy: .public)" + ) + } + } } } } @@ -360,9 +389,15 @@ public final class WebInspectorViewController: UIViewController { let host: UIViewController switch kind { case .compact: - host = CompactTabBarController(session: session) + host = CompactTabBarController( + session: session, + contentStore: presentationContentStore + ) case .regular: - host = RegularTabContentViewController(session: session) + host = RegularTabContentViewController( + session: session, + contentStore: presentationContentStore + ) } host.webInspectorSetDrawsBackgroundTraitOverride(drawsBackgroundStorage) @@ -398,6 +433,10 @@ public final class WebInspectorViewController: UIViewController { } #if DEBUG + package var presentationContentStoreForTesting: PresentationContentStore { + presentationContentStore + } + package func finishRootPresentationLifecycleForTesting(cancelled: Bool = false) { guard cancelled == false else { return diff --git a/Sources/WebInspectorUI/CustomTabResourceViewController.swift b/Sources/WebInspectorUI/CustomTabResourceViewController.swift new file mode 100644 index 00000000..51a62e80 --- /dev/null +++ b/Sources/WebInspectorUI/CustomTabResourceViewController.swift @@ -0,0 +1,140 @@ +#if canImport(UIKit) +import UIKit +import WebInspectorUIBase + +/// Native loading/failure host for one presentation of a custom tab resource. +/// +/// UIKit owns this wrapper's identity. The root presentation store owns the +/// single ready content controller and moves it between wrappers as host layout +/// changes. +@MainActor +package final class CustomTabResourceViewController: UIViewController { + package enum Phase: Equatable { + case loading + case ready + case failed(String) + } + + private let retryAction: @MainActor () -> Void + private var readyViewController: UIViewController? + package private(set) var phase: Phase = .loading + package private(set) var resourceRevision: UInt64 = 0 + + package init(retryAction: @escaping @MainActor () -> Void) { + self.retryAction = retryAction + super.init(nibName: nil, bundle: nil) + showLoading(revision: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + package func showLoading(revision: UInt64) { + guard revision >= resourceRevision else { + return + } + resourceRevision = revision + phase = .loading + removeReadyViewController() + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = String( + localized: "custom.loading.title", + defaultValue: "Loading…", + bundle: WebInspectorUILocalization.bundle + ) + contentUnavailableConfiguration = configuration + } + + package func showReady( + _ viewController: UIViewController, + revision: UInt64 + ) { + guard revision >= resourceRevision else { + return + } + resourceRevision = revision + phase = .ready + contentUnavailableConfiguration = nil + installReadyViewController(viewController) + } + + package func showFailure(_ message: String, revision: UInt64) { + guard revision >= resourceRevision else { + return + } + resourceRevision = revision + phase = .failed(message) + removeReadyViewController() + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "exclamationmark.triangle") + configuration.text = String( + localized: "custom.loading.failed.title", + defaultValue: "Content Unavailable", + bundle: WebInspectorUILocalization.bundle + ) + configuration.secondaryText = message + configuration.button = .bordered() + configuration.button.title = String( + localized: "custom.loading.retry", + defaultValue: "Retry", + bundle: WebInspectorUILocalization.bundle + ) + configuration.buttonProperties.primaryAction = UIAction { [weak self] _ in + self?.retryAction() + } + contentUnavailableConfiguration = configuration + } + + package func synchronouslyResetForOwnerDeinit() { + showLoading(revision: .max) + } + + private func installReadyViewController(_ viewController: UIViewController) { + if readyViewController === viewController, + viewController.parent === self { + return + } + if let previousHost = viewController.parent as? CustomTabResourceViewController { + previousHost.removeReadyViewController() + } else { + viewController.webInspectorDetachFromContainerForReuse() + } + removeReadyViewController() + addChild(viewController) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(viewController.view) + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewController.didMove(toParent: self) + readyViewController = viewController + } + + private func removeReadyViewController() { + guard let readyViewController else { + return + } + if readyViewController.parent === self { + readyViewController.willMove(toParent: nil) + readyViewController.viewIfLoaded?.removeFromSuperview() + readyViewController.removeFromParent() + } + self.readyViewController = nil + } + + #if DEBUG + package var readyViewControllerForTesting: UIViewController? { + readyViewController + } + + package func retryForTesting() { + retryAction() + } + #endif +} +#endif diff --git a/Sources/WebInspectorUI/DOMTabController.swift b/Sources/WebInspectorUI/DOMTabController.swift index a78327e1..12a14b3e 100644 --- a/Sources/WebInspectorUI/DOMTabController.swift +++ b/Sources/WebInspectorUI/DOMTabController.swift @@ -41,60 +41,66 @@ package struct DOMTabController: WebInspectorTab.BuiltInController { } } - package func contentKeys( - for layout: WebInspectorTab.HostLayout, - displayItem: WebInspectorTab.DisplayItem - ) -> [WebInspectorTab.ContentKey] { - switch (layout, displayItem) { - case (.compact, .tab): - [contentKey(ContentID.tree)] - case (.compact, .domElement): - [contentKey(ContentID.element)] - case (_, .customTab): - [] - case (.regular, _): - [ - contentKey(ContentID.tree), - contentKey(ContentID.element), - ] - } - } - package func makeViewController( for displayItem: WebInspectorTab.DisplayItem, session: WebInspectorSession, + contentStore: PresentationContentStore, layout: WebInspectorTab.HostLayout ) -> UIViewController { switch (layout, displayItem) { case (.compact, .tab): DOMCompactNavigationController( - rootViewController: cachedTreeViewController(session: session), - context: session.context + rootViewController: cachedTreeViewController( + session: session, + contentStore: contentStore + ), + context: session.model ) case (.compact, .domElement): - DOMCompactNavigationController(rootViewController: cachedElementViewController(session: session)) + DOMCompactNavigationController( + rootViewController: cachedElementViewController( + session: session, + contentStore: contentStore + ) + ) case (_, .customTab): UIViewController() case (.regular, _): RegularSplitRootViewController( contentViewController: DOMSplitViewController( - treeViewController: cachedTreeViewController(session: session), - elementViewController: cachedElementViewController(session: session), - context: session.context + treeViewController: cachedTreeViewController( + session: session, + contentStore: contentStore + ), + elementViewController: cachedElementViewController( + session: session, + contentStore: contentStore + ), + context: session.model ) ) } } - private func cachedTreeViewController(session: WebInspectorSession) -> DOMTreeViewController { - session.interface.viewController(for: contentKey(ContentID.tree)) { - DOMTreeViewController(context: session.context) + private func cachedTreeViewController( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) -> DOMTreeViewController { + contentStore.viewController( + for: contentKey(ContentID.tree) + ) { + DOMTreeViewController(context: session.model) } } - private func cachedElementViewController(session: WebInspectorSession) -> DOMElementViewController { - session.interface.viewController(for: contentKey(ContentID.element)) { - DOMElementViewController(context: session.context) + private func cachedElementViewController( + session: WebInspectorSession, + contentStore: PresentationContentStore + ) -> DOMElementViewController { + contentStore.viewController( + for: contentKey(ContentID.element) + ) { + DOMElementViewController(context: session.model) } } diff --git a/Sources/WebInspectorUI/NetworkTabController.swift b/Sources/WebInspectorUI/NetworkTabController.swift index 07c69325..b4f1b2d0 100644 --- a/Sources/WebInspectorUI/NetworkTabController.swift +++ b/Sources/WebInspectorUI/NetworkTabController.swift @@ -17,24 +17,39 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { static let detail = "detail" } - package func contentKeys( - for layout: WebInspectorTab.HostLayout, - displayItem: WebInspectorTab.DisplayItem - ) -> [WebInspectorTab.ContentKey] { - [ - contentKey(ContentID.list), - contentKey(ContentID.detail), - ] - } - package func makeViewController( for displayItem: WebInspectorTab.DisplayItem, session: WebInspectorSession, + contentStore: PresentationContentStore, layout: WebInspectorTab.HostLayout ) -> UIViewController { - let model = session.interface.networkPanelModel(for: session.context) - let listViewController = cachedListViewController(session: session, model: model) - let detailViewController = cachedDetailViewController(session: session, model: model) + return contentStore.networkViewController( + context: session.model + ) { [weak contentStore] model in + guard let contentStore else { + preconditionFailure("A Network resource lost its presentation content store.") + } + return readyViewController( + layout: layout, + contentStore: contentStore, + model: model + ) + } + } + + private func readyViewController( + layout: WebInspectorTab.HostLayout, + contentStore: PresentationContentStore, + model: NetworkPanelModel + ) -> UIViewController { + let listViewController = cachedListViewController( + contentStore: contentStore, + model: model + ) + let detailViewController = cachedDetailViewController( + contentStore: contentStore, + model: model + ) switch layout { case .compact: @@ -55,19 +70,23 @@ package struct NetworkTabController: WebInspectorTab.BuiltInController { } private func cachedListViewController( - session: WebInspectorSession, + contentStore: PresentationContentStore, model: NetworkPanelModel ) -> NetworkListViewController { - session.interface.viewController(for: contentKey(ContentID.list)) { + contentStore.viewController( + for: contentKey(ContentID.list) + ) { NetworkListViewController(model: model) } } private func cachedDetailViewController( - session: WebInspectorSession, + contentStore: PresentationContentStore, model: NetworkPanelModel ) -> NetworkDetailViewController { - session.interface.viewController(for: contentKey(ContentID.detail)) { + contentStore.viewController( + for: contentKey(ContentID.detail) + ) { NetworkDetailViewController( model: model, makeBodyViewController: NetworkBodyPreviewFactory.make(scrollEdgeSink:) diff --git a/Sources/WebInspectorUI/NetworkTabResourceViewController.swift b/Sources/WebInspectorUI/NetworkTabResourceViewController.swift new file mode 100644 index 00000000..83c678ed --- /dev/null +++ b/Sources/WebInspectorUI/NetworkTabResourceViewController.swift @@ -0,0 +1,126 @@ +#if canImport(UIKit) +import UIKit +import WebInspectorUIBase +import WebInspectorUINetwork + +/// Native loading/failure container for one Network tab host presentation. +@MainActor +package final class NetworkTabResourceViewController: UIViewController { + package enum Phase: Equatable { + case loading + case ready + case failed(String) + } + + private let makeReadyViewController: @MainActor (NetworkPanelModel) -> UIViewController + private var readyViewController: UIViewController? + private var hasRenderedResourceState = false + package private(set) var phase: Phase = .loading + package private(set) var resourceRevision: UInt64 = 0 + + package init( + makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController + ) { + self.makeReadyViewController = makeReadyViewController + super.init(nibName: nil, bundle: nil) + showLoading(revision: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + package func showLoading(revision: UInt64) { + guard shouldRender(revision: revision) else { + return + } + resourceRevision = revision + phase = .loading + removeReadyViewController() + var configuration = UIContentUnavailableConfiguration.loading() + configuration.text = String( + localized: "network.loading.title", + defaultValue: "Loading Network…", + bundle: WebInspectorUILocalization.bundle + ) + contentUnavailableConfiguration = configuration + } + + package func showReady(_ model: NetworkPanelModel, revision: UInt64) { + guard shouldRender(revision: revision) else { + return + } + resourceRevision = revision + phase = .ready + contentUnavailableConfiguration = nil + let viewController = makeReadyViewController(model) + installReadyViewController(viewController) + } + + package func showFailure(_ message: String, revision: UInt64) { + guard shouldRender(revision: revision) else { + return + } + resourceRevision = revision + phase = .failed(message) + removeReadyViewController() + var configuration = UIContentUnavailableConfiguration.empty() + configuration.image = UIImage(systemName: "exclamationmark.triangle") + configuration.text = String( + localized: "network.loading.failed.title", + defaultValue: "Network Unavailable", + bundle: WebInspectorUILocalization.bundle + ) + configuration.secondaryText = message + contentUnavailableConfiguration = configuration + } + + /// Drops ready content when the root resource owner can no longer publish. + package func synchronouslyResetForOwnerDeinit() { + showLoading(revision: .max) + } + + private func shouldRender(revision: UInt64) -> Bool { + guard hasRenderedResourceState == false || resourceRevision < revision else { + return false + } + hasRenderedResourceState = true + return true + } + + private func installReadyViewController(_ viewController: UIViewController) { + guard readyViewController !== viewController else { + return + } + removeReadyViewController() + addChild(viewController) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(viewController.view) + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewController.didMove(toParent: self) + readyViewController = viewController + } + + private func removeReadyViewController() { + guard let readyViewController else { + return + } + readyViewController.willMove(toParent: nil) + readyViewController.viewIfLoaded?.removeFromSuperview() + readyViewController.removeFromParent() + self.readyViewController = nil + } + + #if DEBUG + package var readyViewControllerForTesting: UIViewController? { + readyViewController + } + #endif +} +#endif diff --git a/Sources/WebInspectorUI/README.md b/Sources/WebInspectorUI/README.md index 343fb339..8c007947 100644 --- a/Sources/WebInspectorUI/README.md +++ b/Sources/WebInspectorUI/README.md @@ -13,9 +13,12 @@ package for symbol-level details. ## Main Areas - `WebInspectorSession`: UIKit facade and custom-tab compatibility owner. It - wraps `WebInspectorContainer` / `WebInspectorContext` and is the UI-facing - inspection lifecycle surface. + owns one stable `WebInspectorModelContext` and is the UI-facing inspection + lifecycle surface. - `WebInspectorViewController`: Public built-in inspector root controller. +- `PresentationContentStore`: Root-owned cache for tab controllers and the + Network/custom async presentation resources; it never belongs to the + session. - `Containers`: Host and wrapper view controllers for compact tab and regular split presentation. - `Tabs`: Public tab API, layout-specific display item projection, content @@ -28,9 +31,11 @@ regular width uses split presentation. ## Data Flow -`WebInspectorSession` owns the UI-facing session lifecycle and exposes the -current `WebInspectorContext`. DOM and Network controllers observe DataKit -models and submit DataKit commands. +`WebInspectorSession` owns the UI-facing session lifecycle and publicly exposes +one stable `WebInspectorModelContext`. DOM and Network controllers observe +DataKit models and submit DataKit commands. Each `WebInspectorViewController` +owns its presentation resources and injects that store into compact or regular +hosts. The UI must not own native bridge objects, protocol envelopes, `TransportSession`, or `TransportBackend` directly. Protocol implementation is diff --git a/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift b/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift index 741cbe80..a1685df4 100644 --- a/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift +++ b/Sources/WebInspectorUI/Tabs/BuiltInTabControllers.swift @@ -10,13 +10,10 @@ extension WebInspectorTab { func displayItems(for layout: WebInspectorTab.HostLayout) -> [WebInspectorTab.DisplayItem] func descriptor(for displayItem: WebInspectorTab.DisplayItem) -> WebInspectorTab.DisplayDescriptor? - func contentKeys( - for layout: WebInspectorTab.HostLayout, - displayItem: WebInspectorTab.DisplayItem - ) -> [WebInspectorTab.ContentKey] func makeViewController( for displayItem: WebInspectorTab.DisplayItem, session: WebInspectorSession, + contentStore: PresentationContentStore, layout: WebInspectorTab.HostLayout ) -> UIViewController } @@ -33,13 +30,6 @@ extension WebInspectorTab.BuiltInController { } return descriptor } - - package func contentKeys( - for layout: WebInspectorTab.HostLayout, - displayItem: WebInspectorTab.DisplayItem - ) -> [WebInspectorTab.ContentKey] { - [WebInspectorTab.ContentKey(tabID: tabID, contentID: "root")] - } } extension WebInspectorTab { @@ -76,39 +66,28 @@ extension WebInspectorTab { package static func makeViewController( for tab: WebInspectorTab, session: WebInspectorSession, + contentStore: PresentationContentStore, hostLayout: WebInspectorTab.HostLayout ) -> UIViewController { makeViewController( for: .tab(tab.id), session: session, - hostLayout: hostLayout, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: hostLayout ) } package static func makeViewController( for displayItem: WebInspectorTab.DisplayItem, session: WebInspectorSession, + contentStore: PresentationContentStore, hostLayout: WebInspectorTab.HostLayout - ) -> UIViewController { - makeViewController( - for: displayItem, - session: session, - hostLayout: hostLayout, - tabs: session.interface.tabs - ) - } - - package static func makeViewController( - for displayItem: WebInspectorTab.DisplayItem, - session: WebInspectorSession, - hostLayout: WebInspectorTab.HostLayout, - tabs: [WebInspectorTab] ) -> UIViewController { if case .domElement = displayItem { return catalog.controller(for: WebInspectorTab.BuiltIn.dom).makeViewController( for: displayItem, session: session, + contentStore: contentStore, layout: hostLayout ) } @@ -121,17 +100,18 @@ extension WebInspectorTab { return UIViewController() } - guard let tab = tabs.first(where: { $0.id == tabID }) else { + guard let tab = session.interface.tabs.first(where: { $0.id == tabID }) else { return UIViewController() } if case let .custom(content) = tab.content { - let viewController = session.interface.viewController(for: customContentKey(for: tab)) { - content.makeViewController(session) - } + let viewController = contentStore.customViewController( + for: customContentKey(for: tab), + session: session, + makeViewController: content.makeViewController + ) switch hostLayout { case .compact: - viewController.webInspectorDetachFromContainerForReuse() return viewController case .regular: return RegularSplitRootViewController(contentViewController: viewController) @@ -145,44 +125,11 @@ extension WebInspectorTab { return controller.makeViewController( for: displayItem, session: session, + contentStore: contentStore, layout: hostLayout ) } - package static func contentKeys( - for hostLayout: WebInspectorTab.HostLayout, - displayItem: WebInspectorTab.DisplayItem, - tabs: [WebInspectorTab] - ) -> [WebInspectorTab.ContentKey] { - if case .domElement = displayItem { - return catalog.controller(for: WebInspectorTab.BuiltIn.dom).contentKeys( - for: hostLayout, - displayItem: displayItem - ) - } - - let tabID: WebInspectorTab.ID - switch displayItem { - case let .tab(id), let .customTab(id): - tabID = id - case .domElement: - return [] - } - - guard let tab = tabs.first(where: { $0.id == tabID }) else { - return [] - } - - guard let controller = catalog.controller(for: tab) else { - return [customContentKey(for: tab)] - } - - return controller.contentKeys( - for: hostLayout, - displayItem: displayItem - ) - } - private static func customContentKey(for tab: WebInspectorTab) -> WebInspectorTab.ContentKey { WebInspectorTab.ContentKey(tabID: tab.id, contentID: "root") } diff --git a/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift new file mode 100644 index 00000000..63f5d992 --- /dev/null +++ b/Sources/WebInspectorUI/Tabs/PresentationContentStore.swift @@ -0,0 +1,556 @@ +#if canImport(UIKit) +import Observation +import UIKit +import WebInspectorDataKit +import WebInspectorUINetwork + +/// Owns view controllers and asynchronous resources whose lifetime is bounded +/// by one root inspector presentation. +@MainActor +@Observable +package final class PresentationContentStore { + package enum NetworkResourceStatus: Equatable, Sendable { + case idle + case loading + case ready + case failed(String) + } + + package enum CustomResourceStatus: Equatable, Sendable { + case loading + case ready + case failed(String) + } + + private enum NetworkResourceState { + case idle + case loading(generation: UInt64) + case ready(generation: UInt64, model: NetworkPanelModel) + case failed(generation: UInt64, message: String) + } + + private enum CustomResourceState { + case loading(generation: UInt64) + case ready(generation: UInt64, viewController: UIViewController) + case failed(generation: UInt64, message: String) + + var generation: UInt64 { + switch self { + case let .loading(generation), + let .ready(generation, _), + let .failed(generation, _): + generation + } + } + } + + private final class WeakNetworkResourceViewController { + weak var value: NetworkTabResourceViewController? + + init(_ value: NetworkTabResourceViewController) { + self.value = value + } + } + + private final class WeakCustomResourceViewController { + weak var value: CustomTabResourceViewController? + + init(_ value: CustomTabResourceViewController) { + self.value = value + } + } + + package typealias NetworkPanelModelFactory = @MainActor ( + _ context: WebInspectorModelContext + ) async throws -> NetworkPanelModel + + @ObservationIgnored private let contentCache = WebInspectorTab.ContentCache() + @ObservationIgnored private let makeNetworkPanelModel: NetworkPanelModelFactory + @ObservationIgnored private var networkResourceTask: Task? + @ObservationIgnored private var networkRetirementTask: Task? + @ObservationIgnored private var networkResourceViewControllers: [WeakNetworkResourceViewController] = [] + @ObservationIgnored private var networkContext: WebInspectorModelContext? + @ObservationIgnored private var customResourceStates: [ + WebInspectorTab.ContentKey: CustomResourceState + ] = [:] + @ObservationIgnored private var customResourceTasks: [ + WebInspectorTab.ContentKey: Task + ] = [:] + @ObservationIgnored private var customResourceViewControllers: [ + WebInspectorTab.ContentKey: [WeakCustomResourceViewController] + ] = [:] + @ObservationIgnored private var customResourceGenerations: [ + WebInspectorTab.ContentKey: UInt64 + ] = [:] + @ObservationIgnored private var customResourceRevisions: [ + WebInspectorTab.ContentKey: UInt64 + ] = [:] + private var networkResourceState: NetworkResourceState = .idle + package private(set) var networkResourceGeneration: UInt64 = 0 + package private(set) var networkResourceRevision: UInt64 = 0 + @ObservationIgnored private var networkRetirementGeneration: UInt64 = 0 + + package init( + makeNetworkPanelModel: @escaping NetworkPanelModelFactory = { context in + try await NetworkPanelModel.make(context: context) + } + ) { + self.makeNetworkPanelModel = makeNetworkPanelModel + } + + isolated deinit { + networkResourceTask?.cancel() + networkRetirementTask?.cancel() + for task in customResourceTasks.values { + task.cancel() + } + if case let .ready(_, model) = networkResourceState { + model.synchronouslyCancelForOwnerDeinit() + } + for resourceViewController in networkResourceViewControllers { + resourceViewController.value?.synchronouslyResetForOwnerDeinit() + } + for resourceViewControllers in customResourceViewControllers.values { + for resourceViewController in resourceViewControllers { + resourceViewController.value?.synchronouslyResetForOwnerDeinit() + } + } + contentCache.removeAll() + } + + package var networkResourceStatus: NetworkResourceStatus { + switch networkResourceState { + case .idle: + .idle + case .loading: + .loading + case .ready: + .ready + case let .failed(_, message): + .failed(message) + } + } + + package func viewController( + for key: WebInspectorTab.ContentKey, + make: () -> Content + ) -> Content { + return contentCache.viewController(for: key, make: make) + } + + package func networkViewController( + context: WebInspectorModelContext, + makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController + ) -> NetworkTabResourceViewController { + if let networkContext { + precondition( + networkContext === context, + "One presentation content store cannot bind multiple model contexts." + ) + } else { + networkContext = context + } + // A UITab owns the view controller returned by its provider. Resource + // state is shared by the root store, but the native wrapper cannot be + // cached and handed to a later UITab instance. + let viewController = NetworkTabResourceViewController( + makeReadyViewController: makeReadyViewController + ) + networkResourceViewControllers.append(WeakNetworkResourceViewController(viewController)) + + switch networkResourceState { + case .idle: + startNetworkResource(context: context) + case .loading, .ready, .failed: + break + } + renderNetworkResource(on: viewController) + return viewController + } + + package func customViewController( + for key: WebInspectorTab.ContentKey, + session: WebInspectorSession, + makeViewController: @escaping @MainActor (WebInspectorSession) async throws -> UIViewController + ) -> CustomTabResourceViewController { + let viewController = CustomTabResourceViewController { [weak self, session] in + self?.retryCustomResource( + for: key, + session: session, + makeViewController: makeViewController + ) + } + customResourceViewControllers[key, default: []].append( + WeakCustomResourceViewController(viewController) + ) + if customResourceStates[key] == nil { + startCustomResource( + for: key, + session: session, + makeViewController: makeViewController + ) + } + renderCustomResource(for: key, on: viewController) + return viewController + } + /// Retires every presentation resource and waits for asynchronous owners. + package func clear() async { + beginNetworkRetirement() + let customTasks = Array(customResourceTasks.values) + for task in customTasks { + task.cancel() + } + for resourceViewControllers in customResourceViewControllers.values { + for resourceViewController in resourceViewControllers { + resourceViewController.value?.synchronouslyResetForOwnerDeinit() + } + } + customResourceStates.removeAll(keepingCapacity: false) + customResourceTasks.removeAll(keepingCapacity: false) + customResourceViewControllers.removeAll(keepingCapacity: false) + customResourceGenerations.removeAll(keepingCapacity: false) + customResourceRevisions.removeAll(keepingCapacity: false) + contentCache.removeAll() + networkResourceViewControllers.removeAll() + networkContext = nil + let retirementGeneration = networkRetirementGeneration + let retirementTask = networkRetirementTask + for task in customTasks { + await task.value + } + await retirementTask?.value + if networkRetirementGeneration == retirementGeneration { + networkRetirementTask = nil + } + } + + private func retryCustomResource( + for key: WebInspectorTab.ContentKey, + session: WebInspectorSession, + makeViewController: @escaping @MainActor (WebInspectorSession) async throws -> UIViewController + ) { + guard case .failed? = customResourceStates[key] else { + return + } + startCustomResource( + for: key, + session: session, + makeViewController: makeViewController + ) + } + + private func startCustomResource( + for key: WebInspectorTab.ContentKey, + session: WebInspectorSession, + makeViewController: @escaping @MainActor (WebInspectorSession) async throws -> UIViewController + ) { + if case .loading? = customResourceStates[key] { + return + } + if case .ready? = customResourceStates[key] { + return + } + let generation = advanceCustomResourceGeneration(for: key) + customResourceStates[key] = .loading(generation: generation) + advanceCustomResourceRevision(for: key) + renderCustomResource(for: key) + + customResourceTasks[key] = Task { @MainActor [weak self, session] in + do { + let viewController = try await makeViewController(session) + guard !Task.isCancelled else { + return + } + self?.completeCustomResource( + .success(viewController), + for: key, + generation: generation + ) + } catch { + guard !Task.isCancelled else { + return + } + self?.completeCustomResource( + .failure(error), + for: key, + generation: generation + ) + } + } + } + + private func completeCustomResource( + _ result: Result, + for key: WebInspectorTab.ContentKey, + generation: UInt64 + ) { + guard customResourceStates[key]?.generation == generation else { + return + } + customResourceTasks[key] = nil + switch result { + case let .success(viewController): + customResourceStates[key] = .ready( + generation: generation, + viewController: viewController + ) + case let .failure(error): + customResourceStates[key] = .failed( + generation: generation, + message: error.localizedDescription + ) + } + advanceCustomResourceRevision(for: key) + renderCustomResource(for: key) + } + + private func renderCustomResource(for key: WebInspectorTab.ContentKey) { + customResourceViewControllers[key] = customResourceViewControllers[key]?.filter { box in + guard let viewController = box.value else { + return false + } + renderCustomResource(for: key, on: viewController) + return true + } ?? [] + } + + private func renderCustomResource( + for key: WebInspectorTab.ContentKey, + on viewController: CustomTabResourceViewController + ) { + let revision = customResourceRevisions[key] ?? 0 + switch customResourceStates[key] { + case .none, .loading: + viewController.showLoading(revision: revision) + case let .ready(_, content): + viewController.showReady(content, revision: revision) + case let .failed(_, message): + viewController.showFailure(message, revision: revision) + } + } + + @discardableResult + private func advanceCustomResourceGeneration( + for key: WebInspectorTab.ContentKey + ) -> UInt64 { + let generation = customResourceGenerations[key] ?? 0 + precondition( + generation < UInt64.max, + "Custom tab resource generation overflowed." + ) + let nextGeneration = generation + 1 + customResourceGenerations[key] = nextGeneration + return nextGeneration + } + + private func advanceCustomResourceRevision( + for key: WebInspectorTab.ContentKey + ) { + let revision = customResourceRevisions[key] ?? 0 + precondition( + revision < UInt64.max, + "Custom tab resource revision overflowed." + ) + customResourceRevisions[key] = revision + 1 + } + + private func startNetworkResource( + context: WebInspectorModelContext + ) { + guard case .idle = networkResourceState else { + return + } + + let generation = advanceNetworkResourceGeneration() + networkResourceState = .loading(generation: generation) + advanceNetworkResourceRevision() + renderNetworkResource() + + let retirementTask = networkRetirementTask + let makeNetworkPanelModel = makeNetworkPanelModel + networkResourceTask = Task { @MainActor [weak self] in + await retirementTask?.value + guard self?.isCurrentNetworkResource(generation: generation) == true else { + return + } + self?.networkRetirementTask = nil + + do { + let model = try await makeNetworkPanelModel(context) + guard let self, + isCurrentNetworkResource(generation: generation) else { + await model.retire() + return + } + networkResourceTask = nil + networkResourceState = .ready( + generation: generation, + model: model + ) + advanceNetworkResourceRevision() + renderNetworkResource() + } catch { + guard let self, + isCurrentNetworkResource(generation: generation) else { + return + } + let message = error.localizedDescription + networkResourceTask = nil + networkResourceState = .failed( + generation: generation, + message: message + ) + advanceNetworkResourceRevision() + renderNetworkResource() + } + } + } + + private func beginNetworkRetirement() { + let loadTask = networkResourceTask + networkResourceTask = nil + loadTask?.cancel() + let readyModel: NetworkPanelModel? + if case let .ready(_, model) = networkResourceState { + readyModel = model + } else { + readyModel = nil + } + let previousRetirementTask = networkRetirementTask + + _ = advanceNetworkResourceGeneration() + networkResourceState = .idle + advanceNetworkResourceRevision() + renderNetworkResource() + + guard loadTask != nil || readyModel != nil || previousRetirementTask != nil else { + networkRetirementTask = nil + return + } + precondition( + networkRetirementGeneration < UInt64.max, + "Network resource retirement generation overflowed." + ) + networkRetirementGeneration += 1 + let retirementGeneration = networkRetirementGeneration + networkRetirementTask = Task { @MainActor [weak self] in + await previousRetirementTask?.value + await loadTask?.value + await readyModel?.retire() + guard let self, + networkRetirementGeneration == retirementGeneration else { + return + } + networkRetirementTask = nil + } + } + + private func isCurrentNetworkResource( + generation: UInt64 + ) -> Bool { + guard networkResourceGeneration == generation else { + return false + } + switch networkResourceState { + case let .loading(resourceGeneration): + return resourceGeneration == generation + case .idle, .ready, .failed: + return false + } + } + + @discardableResult + private func advanceNetworkResourceGeneration() -> UInt64 { + precondition( + networkResourceGeneration < UInt64.max, + "Network resource generation overflowed." + ) + networkResourceGeneration += 1 + return networkResourceGeneration + } + + private func advanceNetworkResourceRevision() { + precondition( + networkResourceRevision < UInt64.max, + "Network resource revision overflowed." + ) + networkResourceRevision += 1 + } + + private func renderNetworkResource() { + networkResourceViewControllers = networkResourceViewControllers.filter { box in + guard let viewController = box.value else { + return false + } + renderNetworkResource(on: viewController) + return true + } + } + + private func renderNetworkResource(on viewController: NetworkTabResourceViewController) { + switch networkResourceState { + case .idle, .loading: + viewController.showLoading(revision: networkResourceRevision) + case let .ready(_, model): + viewController.showReady(model, revision: networkResourceRevision) + case let .failed(_, message): + viewController.showFailure(message, revision: networkResourceRevision) + } + } + + #if DEBUG + package var contentCountForTesting: Int { + contentCache.countForTesting + } + + package var contentCacheForTesting: WebInspectorTab.ContentCache { + contentCache + } + + package var networkPanelModelForTesting: NetworkPanelModel? { + guard case let .ready(_, model) = networkResourceState else { + return nil + } + return model + } + + package func waitForNetworkResourceTaskForTesting() async { + await networkResourceTask?.value + } + + package func waitForNetworkRetirementForTesting() async { + await networkRetirementTask?.value + } + + package func customResourceStatusForTesting( + for key: WebInspectorTab.ContentKey + ) -> CustomResourceStatus? { + switch customResourceStates[key] { + case .none: + nil + case .loading: + .loading + case .ready: + .ready + case let .failed(_, message): + .failed(message) + } + } + + package func customReadyViewControllerForTesting( + for key: WebInspectorTab.ContentKey + ) -> UIViewController? { + guard case let .ready(_, viewController) = customResourceStates[key] else { + return nil + } + return viewController + } + + package func waitForCustomResourceTaskForTesting( + for key: WebInspectorTab.ContentKey + ) async { + await customResourceTasks[key]?.value + } + #endif +} +#endif diff --git a/Sources/WebInspectorUI/Tabs/TabModels.swift b/Sources/WebInspectorUI/Tabs/TabModels.swift index 5a8f86e1..72b1ab94 100644 --- a/Sources/WebInspectorUI/Tabs/TabModels.swift +++ b/Sources/WebInspectorUI/Tabs/TabModels.swift @@ -70,21 +70,12 @@ extension WebInspectorTab { extension WebInspectorTab { @MainActor package final class ContentCache { - private var epoch = 0 private var viewControllerByKey: [WebInspectorTab.ContentKey: UIViewController] = [:] package func viewController( for key: WebInspectorTab.ContentKey, - epoch: Int, make: () -> Content ) -> Content { - if self.epoch != epoch { - // Content built for a previous context epoch must never - // satisfy a lookup from the current one, even when an explicit - // clear was missed or is still pending. - removeAll() - self.epoch = epoch - } if let cachedViewController = viewControllerByKey[key] { if let contentViewController = cachedViewController as? Content { return contentViewController @@ -97,13 +88,6 @@ extension WebInspectorTab { return viewController } - package func prune(retaining keys: Set) { - for (key, viewController) in viewControllerByKey where keys.contains(key) == false { - viewController.webInspectorDetachFromContainerForReuse() - viewControllerByKey[key] = nil - } - } - package func removeAll() { for viewController in viewControllerByKey.values { viewController.webInspectorDetachFromContainerForReuse() @@ -188,20 +172,6 @@ extension WebInspectorTab { } } - package func contentKeys( - for hostLayout: WebInspectorTab.HostLayout, - tabs: [WebInspectorTab] - ) -> Set { - Set( - displayItems(for: hostLayout, tabs: tabs).flatMap { displayItem in - WebInspectorTab.ContentFactory.contentKeys( - for: hostLayout, - displayItem: displayItem, - tabs: tabs - ) - } - ) - } } } #endif diff --git a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift index cb4bd3cd..83cc87be 100644 --- a/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift +++ b/Sources/WebInspectorUI/Tabs/WebInspectorTab.swift @@ -1,10 +1,11 @@ #if canImport(UIKit) import UIKit +import WebInspectorDataKit /// A tab shown by the built-in WebInspectorKit UI. /// /// Use the built-in ``dom`` and ``network`` tabs, or create a custom tab backed -/// by a UIKit view controller factory. +/// by an asynchronous UIKit view controller factory. /// /// Example: /// @@ -34,6 +35,9 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { /// Optional tab image. public let image: UIImage? + + /// Model domains that must be ready before this tab is used. + public let requiredDomains: Set package let content: Content package enum BuiltIn: Hashable { @@ -43,7 +47,7 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { @MainActor package struct CustomContent { - package let makeViewController: @MainActor (WebInspectorSession) -> UIViewController + package let makeViewController: @MainActor (WebInspectorSession) async throws -> UIViewController } package enum Content { @@ -72,28 +76,34 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: ID, title: String, image: UIImage?, - builtIn: BuiltIn + builtIn: BuiltIn, + requiredDomains: Set ) { self.id = id self.title = title self.image = image + self.requiredDomains = requiredDomains self.content = .builtIn(builtIn) } /// Creates an app-provided inspector tab backed by a UIKit view controller. /// /// The factory is called the first time the tab content is needed for a - /// session. WebInspectorKit caches the returned controller for the tab ID - /// and reuses it across compact and regular presentations. + /// root inspector controller. While it runs, WebInspectorKit presents a + /// native loading configuration. A failure presents a retry action. + /// Concurrent host requests join one factory invocation, and the returned + /// controller is reused across compact and regular hosts. public init( id: ID, title: String, image: UIImage? = nil, - makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController + requiredDomains: Set = [], + makeViewController: @escaping @MainActor (_ session: WebInspectorSession) async throws -> UIViewController ) { self.id = id self.title = title self.image = image + self.requiredDomains = requiredDomains self.content = .custom(CustomContent(makeViewController: makeViewController)) } @@ -102,12 +112,14 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: ID, title: String, systemImage: String, - makeViewController: @escaping @MainActor (_ session: WebInspectorSession) -> UIViewController + requiredDomains: Set = [], + makeViewController: @escaping @MainActor (_ session: WebInspectorSession) async throws -> UIViewController ) { self.init( id: id, title: title, image: UIImage(systemName: systemImage), + requiredDomains: requiredDomains, makeViewController: makeViewController ) } @@ -117,7 +129,8 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: "webinspector_dom", title: "DOM", image: UIImage(systemName: "chevron.left.forwardslash.chevron.right"), - builtIn: .dom + builtIn: .dom, + requiredDomains: [.dom, .css] ) /// Built-in Network inspector tab. @@ -125,7 +138,8 @@ public struct WebInspectorTab: Equatable, Hashable, Identifiable { id: "webinspector_network", title: "Network", image: UIImage(systemName: "waveform.path.ecg.rectangle"), - builtIn: .network + builtIn: .network, + requiredDomains: [.network] ) } #endif diff --git a/Sources/WebInspectorUI/WebInspectorUI.docc/WebInspectorUI.md b/Sources/WebInspectorUI/WebInspectorUI.docc/WebInspectorUI.md index 7501338d..3f0bd6de 100644 --- a/Sources/WebInspectorUI/WebInspectorUI.docc/WebInspectorUI.md +++ b/Sources/WebInspectorUI/WebInspectorUI.docc/WebInspectorUI.md @@ -39,9 +39,11 @@ app needs a UIKit panel that shares the same inspection session: let consoleTab = WebInspectorTab( id: "app_console", title: "Console", - systemImage: "terminal" + systemImage: "terminal", + requiredDomains: [.console] ) { session in - ConsoleViewController(inspectorSession: session) + let messages = try await session.model.consoleMessages() + return ConsoleViewController(messages: messages) } let inspector = WebInspectorViewController( @@ -50,7 +52,9 @@ let inspector = WebInspectorViewController( ``` Use ``WebInspectorSession`` when you need explicit access to attachment -lifecycle, page style observation, or the DataKit context used by custom tabs. +lifecycle, page style observation, or the stable DataKit model used by custom +tabs. Custom factories are asynchronous; the root inspector owns their +loading, failure, retry, cancellation, and controller reuse lifecycle. For custom inspector UIs that do not use the built-in UIKit surface, start with WebInspectorDataKit instead. diff --git a/Sources/WebInspectorUIBase/Localizable.xcstrings b/Sources/WebInspectorUIBase/Localizable.xcstrings index 6ad4cd8f..3b67dcbf 100644 --- a/Sources/WebInspectorUIBase/Localizable.xcstrings +++ b/Sources/WebInspectorUIBase/Localizable.xcstrings @@ -4655,6 +4655,40 @@ } } }, + "network.loading.failed.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Network Unavailable" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ネットワークを利用できません" + } + } + } + }, + "network.loading.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading Network…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ネットワークを読み込み中…" + } + } + } + }, "network.search.placeholder" : { "extractionState" : "stale", "localizations" : { @@ -6288,4 +6322,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Sources/WebInspectorUIDOM/DOMCompactNavigationController.swift b/Sources/WebInspectorUIDOM/DOMCompactNavigationController.swift index e81cdda3..2855c989 100644 --- a/Sources/WebInspectorUIDOM/DOMCompactNavigationController.swift +++ b/Sources/WebInspectorUIDOM/DOMCompactNavigationController.swift @@ -17,7 +17,7 @@ package final class DOMCompactNavigationController: UINavigationController { package init( rootViewController: UIViewController, - context: WebInspectorContext + context: WebInspectorModelContext ) { rootViewController.webInspectorDetachFromContainerForReuse() super.init(rootViewController: rootViewController) @@ -101,13 +101,13 @@ extension DOMCompactNavigationController { #Preview("DOM Compact Tree") { DOMCompactNavigationController( - rootViewController: DOMTreeViewController(context: DOMPreviewFixtures.makeWebInspectorContext()) + rootViewController: DOMTreeViewController(context: DOMPreviewFixtures.makeWebInspectorModelContext()) ) } #Preview("DOM Compact Element") { DOMCompactNavigationController( - rootViewController: DOMElementViewController(context: DOMPreviewFixtures.makeWebInspectorContext()) + rootViewController: DOMElementViewController(context: DOMPreviewFixtures.makeWebInspectorModelContext()) ) } #endif diff --git a/Sources/WebInspectorUIDOM/DOMDeletionUndoRegistration.swift b/Sources/WebInspectorUIDOM/DOMDeletionUndoRegistration.swift index bb7e282a..af23006c 100644 --- a/Sources/WebInspectorUIDOM/DOMDeletionUndoRegistration.swift +++ b/Sources/WebInspectorUIDOM/DOMDeletionUndoRegistration.swift @@ -7,14 +7,14 @@ import UIKit enum DOMDeletionUndoRegistration { static func registerDeleteUndo( on undoManager: UndoManager?, - commands: WebInspectorContext.DOMUndoRedoCommands, + capability: DOMUndoCapability, deletedNodeCount: Int ) { guard let undoManager, deletedNodeCount > 0 else { return } let target = DOMUndoCommandTarget( - commands: commands, + capability: capability, undoManager: undoManager, commandCount: deletedNodeCount, actionName: deletedNodeCount == 1 @@ -37,18 +37,18 @@ enum DOMDeletionUndoRegistration { @MainActor private final class DOMUndoCommandTarget: NSObject { - private let commands: WebInspectorContext.DOMUndoRedoCommands + private let capability: DOMUndoCapability private weak var undoManager: UndoManager? private let commandCount: Int private let actionName: String init( - commands: WebInspectorContext.DOMUndoRedoCommands, + capability: DOMUndoCapability, undoManager: UndoManager, commandCount: Int, actionName: String ) { - self.commands = commands + self.capability = capability self.undoManager = undoManager self.commandCount = commandCount self.actionName = actionName @@ -118,13 +118,13 @@ private final class DOMUndoCommandTarget: NSObject { private func undoDeletedNodes() async throws { for _ in 0.. UndoManager? - private let context: WebInspectorContext + private let context: WebInspectorModelContext private var statusTask: Task? private var undoManagerProvider: UndoManagerProvider = { nil } @@ -44,7 +44,7 @@ package final class DOMNavigationItems: NSObject { return item }() - package init(context: WebInspectorContext) { + package init(context: WebInspectorModelContext) { self.context = context super.init() startObservingInspection() @@ -194,7 +194,7 @@ package final class DOMNavigationItems: NSObject { UIAction( title: String(localized: "reload", bundle: WebInspectorUILocalization.bundle), image: UIImage(systemName: "arrow.clockwise"), - attributes: context.status.state == .attached ? [] : [.disabled] + attributes: context.state == .attached ? [] : [.disabled] ) { [weak self] _ in self?.performReloadCommand() } @@ -205,11 +205,11 @@ package final class DOMNavigationItems: NSObject { guard let self else { return } - guard context.status.state == .attached else { + guard context.state == .attached else { return } do { - try await context.page.reload() + try await context.reload() } catch { WebInspectorUIDOMLog.debug("DOM reload failed: \(String(describing: error))") } @@ -236,16 +236,18 @@ package final class DOMNavigationItems: NSObject { } private func deleteSelectedNodeFromNavigation(undoManager: UndoManager?) async { - guard let selectedNode = context.selectedNode else { + guard let selectedNode = try? context.selectedDOMNode else { return } do { - let result = try await context.dom.remove([selectedNode.id]) - let undoCommands = try context.domUndoRedoCommands() + let result = try await context.removeDOMNodes([selectedNode]) + guard let undo = result.undo else { + return + } DOMDeletionUndoRegistration.registerDeleteUndo( on: undoManager, - commands: undoCommands, - deletedNodeCount: result.acceptedNodeIDs.count + capability: undo, + deletedNodeCount: result.appliedNodeIDs.count ) } catch { return @@ -267,7 +269,7 @@ package final class DOMNavigationItems: NSObject { return } do { - try await context.dom.setInspectMode(enabled: !context.isElementPickerEnabled) + try await context.setElementPickerEnabled(!(try context.isElementPickerEnabled)) } catch { WebInspectorUIDOMLog.debug("DOM picker toggle failed: \(String(describing: error))") } @@ -280,7 +282,7 @@ package final class DOMNavigationItems: NSObject { ) } - private func renderPickItem(status: WebInspectorContext.Status) { + private func renderPickItem(status: WebInspectorModelContext.Status) { renderPickItem( isEnabled: status.state == .attached, isSelectingElement: status.isElementPickerEnabled diff --git a/Sources/WebInspectorUIDOM/DOMPreviewFixtures.swift b/Sources/WebInspectorUIDOM/DOMPreviewFixtures.swift index 3aced557..26a578b2 100644 --- a/Sources/WebInspectorUIDOM/DOMPreviewFixtures.swift +++ b/Sources/WebInspectorUIDOM/DOMPreviewFixtures.swift @@ -4,16 +4,16 @@ import WebInspectorProxyKit @MainActor package enum DOMPreviewFixtures { - package static func makeWebInspectorContext( + package static func makeWebInspectorModelContext( document: DOM.Node = previewProxyDocument() - ) -> WebInspectorContext { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + ) -> WebInspectorModelContext { + let context = WebInspectorModelContext.preview() context.seedDOMDocument(document) return context } - package static func firstElement(named localName: String, in context: WebInspectorContext) -> DOMNode? { - guard let rootNode = context.rootNode else { + package static func firstElement(named localName: String, in context: WebInspectorModelContext) -> DOMNode? { + guard let rootNode = try? context.rootDOMNode else { return nil } var stack = [rootNode] diff --git a/Sources/WebInspectorUIDOM/DOMSplitViewController.swift b/Sources/WebInspectorUIDOM/DOMSplitViewController.swift index 045f5425..1325becf 100644 --- a/Sources/WebInspectorUIDOM/DOMSplitViewController.swift +++ b/Sources/WebInspectorUIDOM/DOMSplitViewController.swift @@ -7,7 +7,7 @@ import UIKit package final class DOMSplitViewController: UISplitViewController { private let treeViewController: DOMTreeViewController private let elementViewController: DOMElementViewController - private let context: WebInspectorContext? + private let context: WebInspectorModelContext? private var domNavigationItems: DOMNavigationItems? private lazy var treeNavigationController = RegularSplitColumnNavigationController( rootViewController: treeViewController @@ -16,7 +16,7 @@ package final class DOMSplitViewController: UISplitViewController { rootViewController: elementViewController ) - package convenience init(context: WebInspectorContext) { + package convenience init(context: WebInspectorModelContext) { self.init( treeViewController: DOMTreeViewController(context: context), elementViewController: DOMElementViewController(context: context), @@ -27,7 +27,7 @@ package final class DOMSplitViewController: UISplitViewController { package init( treeViewController: DOMTreeViewController, elementViewController: DOMElementViewController, - context: WebInspectorContext? = nil + context: WebInspectorModelContext? = nil ) { self.treeViewController = treeViewController self.elementViewController = elementViewController @@ -145,7 +145,7 @@ extension DOMSplitViewController { @MainActor private enum DOMSplitViewControllerPreview { static func makeViewController() -> DOMSplitViewController { - DOMSplitViewController(context: DOMPreviewFixtures.makeWebInspectorContext()) + DOMSplitViewController(context: DOMPreviewFixtures.makeWebInspectorModelContext()) } } #endif diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementStylePresentationModel.swift b/Sources/WebInspectorUIDOM/Element/DOMElementStylePresentationModel.swift index 02d40f00..a6b4cc71 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementStylePresentationModel.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementStylePresentationModel.swift @@ -98,7 +98,6 @@ package final class DOMElementStyleSnapshotCoordinator { package enum ApplyMode: Equatable { case none case diff(animated: Bool) - case reloadData } package enum PlaceholderMode: Equatable { @@ -110,6 +109,10 @@ package final class DOMElementStyleSnapshotCoordinator { package var snapshot: Snapshot? package var applyMode: ApplyMode package var placeholderMode: PlaceholderMode + /// The selected CSS resource changed while its diffable identifiers + /// remained equal. Visible cells must bind the replacement property + /// identities without reloading collection topology. + package var rebindVisiblePropertyRows: Bool /// Sections whose rendered header content changed while keeping /// their identity; visible header views must be re-bound because /// diffable snapshots do not reconfigure supplementary views. @@ -119,11 +122,13 @@ package final class DOMElementStyleSnapshotCoordinator { snapshot: Snapshot?, applyMode: ApplyMode, placeholderMode: PlaceholderMode, + rebindVisiblePropertyRows: Bool = false, updatedSectionIDs: Set = [] ) { self.snapshot = snapshot self.applyMode = applyMode self.placeholderMode = placeholderMode + self.rebindVisiblePropertyRows = rebindVisiblePropertyRows self.updatedSectionIDs = updatedSectionIDs } } @@ -133,29 +138,6 @@ package final class DOMElementStyleSnapshotCoordinator { var hasRenderedLoadedSnapshot = false } - /// The fields a property row renders. Sections and properties are value - /// types, so "did this row change" is decided by content comparison - /// (the legacy coordinator compared object identity instead). - private struct PropertyRenderContent: Equatable { - var name: String - var value: String - var priority: String? - var text: String? - var status: CSSStyleProperty.Status - var isEditable: Bool - var isModifiedByInspector: Bool - - init(_ property: CSSStyleProperty) { - name = property.name - value = property.value - priority = property.priority - text = property.text - status = property.status - isEditable = property.isEditable - isModifiedByInspector = property.isModifiedByInspector - } - } - /// The fields a section header renders. private struct SectionRenderContent: Equatable { var title: String? @@ -173,11 +155,11 @@ package final class DOMElementStyleSnapshotCoordinator { private struct VisibleRenderContent: Equatable { var sectionContents: [CSSStyleSection.ID: SectionRenderContent] - var propertyContents: [DOMElementStylePresentationItemIdentifier: PropertyRenderContent] + var propertyObjectIDs: [DOMElementStylePresentationItemIdentifier: ObjectIdentifier] static let empty = VisibleRenderContent( sectionContents: [:], - propertyContents: [:] + propertyObjectIDs: [:] ) } @@ -270,60 +252,62 @@ package final class DOMElementStyleSnapshotCoordinator { let oldItemIDs = visibleItemIDs let oldRenderContent = visibleRenderContent let replacesSelection = selectionEpoch?.hasRenderedLoadedSnapshot == false - let hadVisibleSnapshot = visibleSections.isEmpty == false displayedSections = sections rebuildVisibleSections() - var snapshot = diffableSnapshot() + let snapshot = diffableSnapshot() let newRenderContent = makeVisibleRenderContent() let hasStructuralChanges = Self.hasStructuralChanges( oldSectionIDs: oldSectionIDs, oldItemIDs: oldItemIDs, snapshot: snapshot ) - let updatedItemIDs = Self.updatedKeys( - old: oldRenderContent.propertyContents, - new: newRenderContent.propertyContents - ) let updatedSectionIDs = Self.updatedKeys( old: oldRenderContent.sectionContents, new: newRenderContent.sectionContents ) + let replacedPropertyIDs = Self.updatedKeys( + old: oldRenderContent.propertyObjectIDs, + new: newRenderContent.propertyObjectIDs + ) selectionEpoch?.hasRenderedLoadedSnapshot = true visibleRenderContent = newRenderContent if replacesSelection { + if hasStructuralChanges { + return SnapshotUpdate( + snapshot: snapshot, + applyMode: .diff(animated: false), + placeholderMode: .none, + rebindVisiblePropertyRows: true, + updatedSectionIDs: updatedSectionIDs + ) + } return SnapshotUpdate( - snapshot: snapshot, - applyMode: hadVisibleSnapshot ? .reloadData : .diff(animated: false), - placeholderMode: .none - ) - } - if hasStructuralChanges { - snapshot.reconfigureItems(Array(updatedItemIDs)) - return SnapshotUpdate( - snapshot: snapshot, - applyMode: .diff(animated: true), + snapshot: nil, + applyMode: .none, placeholderMode: .none, + rebindVisiblePropertyRows: true, updatedSectionIDs: updatedSectionIDs ) } - if updatedItemIDs.isEmpty == false { - snapshot.reconfigureItems(Array(updatedItemIDs)) + if hasStructuralChanges { return SnapshotUpdate( snapshot: snapshot, - applyMode: .diff(animated: false), + applyMode: .diff(animated: true), placeholderMode: .none, + rebindVisiblePropertyRows: replacedPropertyIDs.isEmpty == false, updatedSectionIDs: updatedSectionIDs ) } - if updatedSectionIDs.isEmpty == false { + if updatedSectionIDs.isEmpty == false || replacedPropertyIDs.isEmpty == false { return SnapshotUpdate( snapshot: nil, applyMode: .none, placeholderMode: .none, + rebindVisiblePropertyRows: replacedPropertyIDs.isEmpty == false, updatedSectionIDs: updatedSectionIDs ) } @@ -331,13 +315,9 @@ package final class DOMElementStyleSnapshotCoordinator { } /// Pending phases (`loading`/`needsRefresh`) keep the displayed row - /// structure frozen until the follow-up refresh lands. When the pending - /// styles belong to the already-rendered selection, same-identity content - /// changes are still pushed through the reconfigure path: DataKit's - /// `applySetStyleText` rewrites sections in place (keeping identity) and - /// marks the styles stale, and the toggled declaration text plus the - /// modified-by-inspector badge must update immediately (the legacy build - /// rendered this through per-object observation in the cells). + /// structure frozen until the follow-up refresh lands. Same-identity + /// property content is rendered by each row's Observation binding and + /// never becomes a collection snapshot operation. private func updatePendingSnapshot(_ sections: [CSSStyleSection]) -> SnapshotUpdate { guard displayedSections != nil else { return updateUnavailableSnapshot() @@ -350,7 +330,7 @@ package final class DOMElementStyleSnapshotCoordinator { sections: sections, expandedUnusedVariableSectionIDs: expandedUnusedVariableSectionIDs ) - var snapshot = DOMElementStyleDiffableSnapshotBuilder.makeSnapshot( + let snapshot = DOMElementStyleDiffableSnapshotBuilder.makeSnapshot( visibleSections: prospectiveVisibleSections ) guard Self.hasStructuralChanges( @@ -366,27 +346,19 @@ package final class DOMElementStyleSnapshotCoordinator { let oldRenderContent = visibleRenderContent let newRenderContent = makeVisibleRenderContent() visibleRenderContent = newRenderContent - let updatedItemIDs = Self.updatedKeys( - old: oldRenderContent.propertyContents, - new: newRenderContent.propertyContents - ) let updatedSectionIDs = Self.updatedKeys( old: oldRenderContent.sectionContents, new: newRenderContent.sectionContents ) - guard updatedItemIDs.isEmpty == false else { - return SnapshotUpdate( - snapshot: nil, - applyMode: .none, - placeholderMode: .none, - updatedSectionIDs: updatedSectionIDs - ) - } - snapshot.reconfigureItems(Array(updatedItemIDs)) + let replacedPropertyIDs = Self.updatedKeys( + old: oldRenderContent.propertyObjectIDs, + new: newRenderContent.propertyObjectIDs + ) return SnapshotUpdate( - snapshot: snapshot, - applyMode: .diff(animated: false), + snapshot: nil, + applyMode: .none, placeholderMode: .none, + rebindVisiblePropertyRows: replacedPropertyIDs.isEmpty == false, updatedSectionIDs: updatedSectionIDs ) } @@ -439,7 +411,9 @@ package final class DOMElementStyleSnapshotCoordinator { sectionsByID[section.id] = section } var sectionContents: [CSSStyleSection.ID: SectionRenderContent] = [:] - var propertyContents: [DOMElementStylePresentationItemIdentifier: PropertyRenderContent] = [:] + var propertyObjectIDs: [ + DOMElementStylePresentationItemIdentifier: ObjectIdentifier + ] = [:] for visibleSection in visibleSections { guard let section = sectionsByID[visibleSection.id] else { @@ -450,13 +424,13 @@ package final class DOMElementStyleSnapshotCoordinator { guard let property = property(for: item, in: section) else { continue } - propertyContents[item] = PropertyRenderContent(property) + propertyObjectIDs[item] = ObjectIdentifier(property) } } return VisibleRenderContent( sectionContents: sectionContents, - propertyContents: propertyContents + propertyObjectIDs: propertyObjectIDs ) } diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyCollectionCell.swift b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyCollectionCell.swift index b8855e03..68de7943 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyCollectionCell.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyCollectionCell.swift @@ -1,6 +1,7 @@ #if canImport(UIKit) import WebInspectorUIBase import WebInspectorDataKit +import ObservationBridge import UIKit @MainActor @@ -10,6 +11,7 @@ package final class DOMElementStylePropertyCollectionCell: UICollectionViewListC private let propertyView = DOMElementStylePropertyView() private var property: CSSStyleProperty? private var toggleAction: DOMElementStylePropertyView.ToggleAction? + private var propertyObservation: PortableObservationTracking.Token? override package init(frame: CGRect) { super.init(frame: frame) @@ -21,6 +23,10 @@ package final class DOMElementStylePropertyCollectionCell: UICollectionViewListC nil } + isolated deinit { + propertyObservation?.cancel() + } + override package func prepareForReuse() { super.prepareForReuse() clear() @@ -34,19 +40,32 @@ package final class DOMElementStylePropertyCollectionCell: UICollectionViewListC ) } - /// Properties are value types: the cell renders the configured value - /// once and is re-rendered through the coordinator's reconfigure path - /// when the row's content changes. + /// The cell keeps its property identity and observes local presentation + /// state without asking the collection view to reconfigure the row. package func bind( property: CSSStyleProperty, onToggle: DOMElementStylePropertyView.ToggleAction? ) { self.property = property toggleAction = onToggle - render(property) + propertyView.bind(property: property, onToggle: onToggle) + propertyObservation?.cancel() + propertyObservation = withPortableContinuousObservation { [weak self, weak property] _ in + guard let self, + let property, + self.property === property else { + return + } + self.renderBackground( + isModifiedByInspector: property.isModifiedByInspector, + state: self.configurationState + ) + } } package func clear() { + propertyObservation?.cancel() + propertyObservation = nil property = nil toggleAction = nil propertyView.clear() @@ -70,14 +89,6 @@ package final class DOMElementStylePropertyCollectionCell: UICollectionViewListC ]) } - private func render(_ property: CSSStyleProperty) { - propertyView.render(property: property, onToggle: toggleAction) - renderBackground( - isModifiedByInspector: property.isModifiedByInspector, - state: configurationState - ) - } - private func renderBackground( isModifiedByInspector: Bool, state: UICellConfigurationState diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift index 40a66244..5ff0bd55 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementStylePropertyView.swift @@ -1,16 +1,19 @@ #if canImport(UIKit) import WebInspectorUIBase import WebInspectorDataKit +import ObservationBridge import UIKit @MainActor package final class DOMElementStylePropertyView: UIView { - package typealias ToggleAction = @MainActor (CSSStyleProperty.ID, Bool) -> Bool + package typealias ToggleAction = @MainActor (CSSStyleProperty, Bool) async -> Bool private let declarationTextView = UITextView() private let toggleSwitch = UISwitch() private var property: CSSStyleProperty? private var toggleAction: ToggleAction? + private var toggleTask: Task? + private var propertyObservation: PortableObservationTracking.Token? override package init(frame: CGRect) { super.init(frame: frame) @@ -22,23 +25,38 @@ package final class DOMElementStylePropertyView: UIView { nil } - package func bind( - property: CSSStyleProperty, - onToggle: ToggleAction? = nil - ) { - render(property: property, onToggle: onToggle) + isolated deinit { + propertyObservation?.cancel() } - package func render( + package func bind( property: CSSStyleProperty, onToggle: ToggleAction? = nil ) { + if self.property !== property { + // A submitted mutation is committed user work and outlives cell + // reuse. Drop only this view's completion handle. + toggleTask = nil + } + propertyObservation?.cancel() self.property = property toggleAction = onToggle - renderAll(from: property) + propertyObservation = withPortableContinuousObservation { [weak self, weak property] _ in + guard let self, + let property, + self.property === property else { + return + } + self.renderAll(from: property) + } } package func clear() { + // Do not cancel an accepted mutation when the collection view reuses + // its presentation cell. + toggleTask = nil + propertyObservation?.cancel() + propertyObservation = nil property = nil toggleAction = nil declarationTextView.attributedText = nil @@ -98,7 +116,9 @@ package final class DOMElementStylePropertyView: UIView { } private func renderToggleState(from property: CSSStyleProperty, animated: Bool = false) { - toggleSwitch.setOn(property.isEnabled, animated: animated) + if property.isMutationPending == false { + toggleSwitch.setOn(property.isEnabled, animated: animated) + } toggleSwitch.isEnabled = canToggle(property) } @@ -131,13 +151,29 @@ package final class DOMElementStylePropertyView: UIView { return } - if toggleAction?(property.id, requestedEnabledState) != true { + guard let toggleAction else { toggleSwitch.setOn(property.isEnabled, animated: false) + return + } + toggleSwitch.isEnabled = false + toggleTask = Task { @MainActor [weak self] in + _ = await toggleAction(property, requestedEnabledState) + guard let self, + self.property === property else { + return + } + toggleTask = nil + if let currentProperty = self.property { + renderToggleState(from: currentProperty) + } } } private func canToggle(_ property: CSSStyleProperty) -> Bool { - property.isEditable && toggleAction != nil + property.isEditable + && property.isMutationPending == false + && toggleAction != nil + && toggleTask == nil } private func declarationText(for property: CSSStyleProperty) -> NSAttributedString { @@ -206,16 +242,8 @@ package final class DOMElementStylePropertyView: UIView { stackView.isLayoutMarginsRelativeArrangement = true for property in DOMElementStylePropertyViewPreviewData.makeProperties() { - // Static preview: values do not observe, so re-render the row - // locally with the toggled value. let row = DOMElementStylePropertyView() - @MainActor func renderRow(_ property: CSSStyleProperty) { - row.render(property: property) { _, enabled in - renderRow(DOMElementStylePropertyViewPreviewData.toggled(property, enabled: enabled)) - return true - } - } - renderRow(property) + row.bind(property: property) stackView.addArrangedSubview(row) } @@ -224,26 +252,6 @@ package final class DOMElementStylePropertyView: UIView { @MainActor private enum DOMElementStylePropertyViewPreviewData { - static func sourceText(for property: CSSStyleProperty) -> String { - var text = "\(property.name): \(property.value)" - if let priority = property.priority, !priority.isEmpty { - text += " !\(priority)" - } - return text + ";" - } - - static func toggled(_ property: CSSStyleProperty, enabled: Bool) -> CSSStyleProperty { - CSSStyleProperty( - id: property.id, - name: property.name, - value: property.value, - priority: property.priority, - text: enabled ? sourceText(for: property) : "/* \(sourceText(for: property)) */", - status: enabled ? .active : .disabled, - isEditable: property.isEditable - ) - } - static func makeProperties() -> [CSSStyleProperty] { [ CSSStyleProperty( diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift index 3f8ac962..2b07b2b5 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController+Preview.swift @@ -15,9 +15,9 @@ import UIKit @MainActor private enum DOMElementViewControllerPreview { static func makeViewController() -> UINavigationController { - let context = DOMPreviewFixtures.makeWebInspectorContext() + let context = DOMPreviewFixtures.makeWebInspectorModelContext() if let body = DOMPreviewFixtures.firstElement(named: "body", in: context) { - context.select(body) + try! context.selectDOMNode(body) context.seedSelectedNodeStyles(matchedStyles: previewMatchedStyles()) } return UINavigationController(rootViewController: DOMElementViewController(context: context)) diff --git a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift index 0a3459d0..11385c40 100644 --- a/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift +++ b/Sources/WebInspectorUIDOM/Element/DOMElementViewController.swift @@ -7,9 +7,14 @@ import UIKit @MainActor package final class DOMElementViewController: UICollectionViewController { - private let context: WebInspectorContext + private let context: WebInspectorModelContext private var statusTask: Task? + private var styleHydrationTask: Task? + private var styleHydrationGeneration: UInt64 = 0 + private var isStyleHydrationActive = false private var selectedStylesObservation: PortableObservationTracking.Token? + private var selectedStylesRenderTask: Task? + private var selectedStylesRenderGeneration: UInt64 = 0 private var observedSelectedNodeObjectID: ObjectIdentifier? private var hasBoundSelectedNode = false private let styleSnapshotCoordinator = DOMElementStyleSnapshotCoordinator() @@ -31,7 +36,7 @@ package final class DOMElementViewController: UICollectionViewController { private lazy var dataSource = makeDataSource() - package init(context: WebInspectorContext) { + package init(context: WebInspectorModelContext) { self.context = context super.init(collectionViewLayout: Self.makeLayout()) } @@ -45,7 +50,8 @@ package final class DOMElementViewController: UICollectionViewController { #if DEBUG resolveStyleRenderWaitersForTesting(result: false) #endif - context.css.setStyleHydrationActive(false) + styleHydrationTask?.cancel() + selectedStylesRenderTask?.cancel() statusTask?.cancel() selectedStylesObservation?.cancel() } @@ -67,11 +73,17 @@ package final class DOMElementViewController: UICollectionViewController { override package func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) - context.css.setStyleHydrationActive(true) + isStyleHydrationActive = true + if let selectedNode = try? context.selectedDOMNode { + scheduleSelectedStylesRender(for: selectedNode) + hydrateStylesIfNeeded(for: selectedNode, retryFailure: true) + } } override package func viewDidDisappear(_ animated: Bool) { - context.css.setStyleHydrationActive(false) + isStyleHydrationActive = false + cancelStyleHydration() + cancelSelectedStylesRender() super.viewDidDisappear(animated) } @@ -181,13 +193,13 @@ package final class DOMElementViewController: UICollectionViewController { } private func startObservingState() { - bindSelectedNode(context.selectedNode) + bindSelectedNode(try? context.selectedDOMNode) statusTask = Task { @MainActor [weak self, context] in for await status in context.statusUpdates { guard let self else { return } - bindSelectedNode(status.selectedNodeID.flatMap { context.node(for: $0) }) + bindSelectedNode(status.selectedNodeID.flatMap { try? context.domNode(id: $0) }) } } } @@ -199,6 +211,8 @@ package final class DOMElementViewController: UICollectionViewController { } hasBoundSelectedNode = true observedSelectedNodeObjectID = nodeObjectID + cancelStyleHydration() + cancelSelectedStylesRender() selectedStylesObservation?.cancel() guard let node else { selectedStylesObservation = nil @@ -214,14 +228,128 @@ package final class DOMElementViewController: UICollectionViewController { self.renderSelectedStyles(nil) return } - self.renderSelectedStyles(node.elementStyles) + let phase = self.sampleSelectedStylesDependencies(node.elementStyles) + self.scheduleSelectedStylesRender(for: node) + if phase == .needsRefresh { + self.hydrateStylesIfNeeded(for: node, retryFailure: false) + } } selectedStylesObservation = token + hydrateStylesIfNeeded(for: node, retryFailure: true) + } + + private func hydrateStylesIfNeeded( + for node: DOMNode, + retryFailure: Bool + ) { + guard isStyleHydrationActive, + styleHydrationTask == nil else { + return + } + + let operation: @MainActor () async throws -> Void + switch node.elementStyles?.phase { + case nil, .unavailable: + operation = { [context] in + _ = try await context.cssStyles(for: node) + } + case .failed where retryFailure: + operation = { [context] in + _ = try await context.cssStyles(for: node) + } + case .needsRefresh: + operation = { [context] in + try await context.refreshCSSStyles(for: node) + } + case .loading, .loaded, .failed: + return + } + + precondition( + styleHydrationGeneration < UInt64.max, + "DOM style hydration generation overflowed." + ) + styleHydrationGeneration += 1 + let generation = styleHydrationGeneration + styleHydrationTask = Task { @MainActor [weak self] in + do { + try await operation() + } catch is CancellationError { + // Selection and visibility changes cancel obsolete hydration. + } catch { + WebInspectorUIDOMLog.error( + "CSS style hydration failed nodeID=\(String(describing: node.id)): " + + String(describing: error) + ) + } + guard let self, + styleHydrationGeneration == generation else { + return + } + styleHydrationTask = nil + } + } + + private func cancelStyleHydration() { + styleHydrationTask?.cancel() + styleHydrationTask = nil + precondition( + styleHydrationGeneration < UInt64.max, + "DOM style hydration generation overflowed." + ) + styleHydrationGeneration += 1 + } + + /// Observation callbacks only sample dependencies. Rendering is deferred + /// until replacement tracking has been installed, then reads the latest + /// model state so a mutation arriving during the callback cannot be lost. + private func scheduleSelectedStylesRender(for node: DOMNode) { + cancelSelectedStylesRender() + let nodeObjectID = ObjectIdentifier(node) + let generation = selectedStylesRenderGeneration + selectedStylesRenderTask = Task { @MainActor [weak self, weak node] in + await Task.yield() + guard !Task.isCancelled, + let self, + let node, + self.selectedStylesRenderGeneration == generation, + self.observedSelectedNodeObjectID == nodeObjectID else { + return + } + self.selectedStylesRenderTask = nil + self.renderSelectedStyles(node.elementStyles) + } + } + + /// Registers every property dependency that can change collection + /// topology. Individual visible rows own their remaining property reads. + private func sampleSelectedStylesDependencies(_ styles: CSSStyles?) -> CSSStyles.Phase? { + guard let styles else { + return nil + } + let phase = styles.phase + for section in styles.sections { + for property in section.style.properties { + _ = property.name + _ = property.value + _ = property.status + } + } + return phase } - /// Renders the selected node's styles. Runs inside the observation - /// closure so the coordinator's reads of `phase`/`sections` register - /// Observation tracking on the `CSSStyles` model. + private func cancelSelectedStylesRender() { + selectedStylesRenderTask?.cancel() + selectedStylesRenderTask = nil + precondition( + selectedStylesRenderGeneration < UInt64.max, + "DOM style render generation overflowed." + ) + selectedStylesRenderGeneration += 1 + } + + /// Renders the selected node's latest styles after the observation + /// tracking pass has completed. private func renderSelectedStyles(_ styles: CSSStyles?) { guard let styles else { applySnapshotUpdate(styleSnapshotCoordinator.updateUnavailable()) @@ -232,19 +360,15 @@ package final class DOMElementViewController: UICollectionViewController { private func applySnapshotUpdate(_ update: DOMElementStyleSnapshotCoordinator.SnapshotUpdate) { applyPlaceholder(update.placeholderMode) - rebindVisibleHeaders(update.updatedSectionIDs) switch update.applyMode { case .none: + applyVisibleBindings(update) #if DEBUG finishStyleRenderForTesting() #endif case let .diff(animated): guard let snapshot = update.snapshot else { -#if DEBUG - lastSnapshotApplyModeForTesting = .none - finishStyleRenderForTesting() -#endif - return + preconditionFailure("A CSS structural diff requires a snapshot.") } #if DEBUG let applyMode = DOMElementStyleSnapshotCoordinator.ApplyMode.diff(animated: animated) @@ -256,25 +380,7 @@ package final class DOMElementViewController: UICollectionViewController { let shouldAnimateSnapshot = animated #endif dataSource.apply(snapshot, animatingDifferences: shouldAnimateSnapshot) { [weak self] in -#if DEBUG - self?.finishStyleRenderForTesting() -#endif - } - case .reloadData: - guard let snapshot = update.snapshot else { -#if DEBUG - lastSnapshotApplyModeForTesting = .none - finishStyleRenderForTesting() -#endif - return - } -#if DEBUG - let applyMode = DOMElementStyleSnapshotCoordinator.ApplyMode.reloadData - lastSnapshotApplyModeForTesting = applyMode - styleSnapshotApplyModesForTesting.append(applyMode) - styleSnapshotApplyCountForTesting += 1 -#endif - dataSource.applySnapshotUsingReloadData(snapshot) { [weak self] in + self?.applyVisibleBindings(update) #if DEBUG self?.finishStyleRenderForTesting() #endif @@ -282,6 +388,15 @@ package final class DOMElementViewController: UICollectionViewController { } } + private func applyVisibleBindings( + _ update: DOMElementStyleSnapshotCoordinator.SnapshotUpdate + ) { + if update.rebindVisiblePropertyRows { + rebindVisiblePropertyRows() + } + rebindVisibleHeaders(update.updatedSectionIDs) + } + private func applyPlaceholder(_ placeholderMode: DOMElementStyleSnapshotCoordinator.PlaceholderMode) { switch placeholderMode { case .none: @@ -328,6 +443,19 @@ package final class DOMElementViewController: UICollectionViewController { } } + private func rebindVisiblePropertyRows() { + for indexPath in collectionView.indexPathsForVisibleItems { + guard let item = dataSource.itemIdentifier(for: indexPath), + let section = section(for: item.sectionID), + let property = property(for: item, in: section), + let cell = collectionView.cellForItem(at: indexPath) + as? DOMElementStylePropertyCollectionCell else { + continue + } + cell.bind(property: property, onToggle: toggleAction()) + } + } + private func section(for sectionID: CSSStyleSection.ID) -> CSSStyleSection? { styleSnapshotCoordinator.section(for: sectionID) } @@ -347,8 +475,24 @@ package final class DOMElementViewController: UICollectionViewController { } private func toggleAction() -> DOMElementStylePropertyView.ToggleAction? { - return { [weak context] propertyID, enabled in - context?.css.requestSetProperty(propertyID, enabled: enabled) ?? false + return { [weak context] property, enabled in + guard let context else { + return false + } + do { + _ = try await context.setCSSProperty( + property, + enabled: enabled, + undo: .automatic + ) + return true + } catch { + WebInspectorUIDOMLog.error( + "CSS property toggle failed name=\(property.name) " + + "id=\(property.id.rawValue): \(String(describing: error))" + ) + return false + } } } @@ -388,7 +532,7 @@ package final class DOMElementViewController: UICollectionViewController { } package func renderCurrentStylesForTesting() { - renderSelectedStyles(context.selectedNode?.elementStyles) + renderSelectedStyles((try? context.selectedDOMNode)?.elementStyles) } private func finishStyleRenderForTesting() { diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeMenu.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeMenu.swift index 23011d7e..e1fe9ccf 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeMenu.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeMenu.swift @@ -11,7 +11,7 @@ typealias DOMTreeMenuDeleteNodesAction = @MainActor ([DOMNode.ID], UndoManager?) @MainActor @Observable final class DOMTreeMenuModel { - let context: WebInspectorContext + let context: WebInspectorModelContext var nodeIDs: [DOMNode.ID] = [] var selectedText: String? var localMarkupTextByNodeID: [DOMNode.ID: String] = [:] @@ -23,7 +23,7 @@ final class DOMTreeMenuModel { private let deleteNodesAction: DOMTreeMenuDeleteNodesAction? init( - context: WebInspectorContext, + context: WebInspectorModelContext, copyNodeTextAction: DOMTreeMenuCopyNodeTextAction?, deleteNodesAction: DOMTreeMenuDeleteNodesAction? ) { diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift index 5f08a18a..478bc4a8 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeTextView.swift @@ -36,7 +36,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat paragraphStyle.paragraphSpacingBefore = 0 return paragraphStyle }() - private let context: WebInspectorContext + private let context: WebInspectorModelContext private let treeController: DOMTreeController private var currentTreeSnapshot: DOMTreeSnapshot private var selectionRevision: UInt64 @@ -69,6 +69,8 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat private let rowRenderBuildCoordinator: DOMTreeTextView.RowRenderBuildCoordinator private var hoveredNodeID: DOMNode.ID? private var pageHighlightTask: Task? + private var pageHighlightTaskID: UUID? + private var pageHighlightOperation: PageHighlightOperation? private var pageHighlightIntent: PageHighlightIntent? private var requestedChildNodeIDs: Set = [] private let findDecorationState = DOMTreeTextView.FindDecorationState() @@ -154,6 +156,11 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat case restoreSelectionAfterHover } + private enum PageHighlightOperation: Equatable { + case highlight(DOMNode.ID, reason: PageHighlightReason) + case restoreSelectionAfterHover + } + private struct SelectionReconciliationState { private var lastReconciledSelectionRevision: UInt64? private(set) var pendingSelectionRevision: UInt64? @@ -187,7 +194,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat #endif init( - context: WebInspectorContext, + context: WebInspectorModelContext, requestChildrenAction: RequestChildrenAction? = nil, highlightNodeAction: HighlightNodeAction? = nil, restoreHighlightAction: RestoreHighlightAction? = nil, @@ -195,7 +202,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat deleteNodesAction: DeleteNodesAction? = nil ) { self.context = context - let treeController = context.dom.treeController() + let treeController = context.rootTreeController() self.treeController = treeController self.currentTreeSnapshot = treeController.snapshot self.selectionRevision = currentTreeSnapshot.revision @@ -1079,7 +1086,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat @discardableResult private func select(_ nodeID: DOMNode.ID) -> Bool { do { - try context.dom.select(nodeID, reveal: .selectAndScroll) + try context.selectNode(nodeID, reveal: .selectAndScroll) } catch { WebInspectorUIDOMLog.debug("DOM tree selection failed nodeID=\(String(describing: nodeID)): \(String(describing: error))") return false @@ -1254,6 +1261,10 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat currentTreeSnapshot.selectedNodeID == nodeID else { return } + let operation = PageHighlightOperation.highlight(nodeID, reason: .selection) + guard pageHighlightOperation != operation else { + return + } highlightPageNode(nodeID, reason: .selection) } @@ -1261,6 +1272,10 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat guard isRenderingActive else { return } + let operation = PageHighlightOperation.highlight(nodeID, reason: reason) + guard pageHighlightOperation != operation else { + return + } switch reason { case .selection: pageHighlightIntent = .selection(nodeID) @@ -1268,12 +1283,18 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat case .hover: cancelPageHighlightTask() } + let taskID = UUID() + pageHighlightTaskID = taskID + pageHighlightOperation = operation pageHighlightTask = Task { @MainActor [weak self, highlightNodeAction] in await Task.yield() guard !Task.isCancelled, let self else { return } + defer { + self.finishPageHighlightTask(id: taskID) + } switch reason { case .selection: guard self.currentTreeSnapshot.selectedNodeID == nodeID else { @@ -1292,6 +1313,7 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat try await highlightNodeAction(nodeID, reason.owner) } if reason == .selection, + self.pageHighlightTaskID == taskID, self.pageHighlightIntent == .selection(nodeID) { self.pageHighlightIntent = nil } @@ -1304,17 +1326,28 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat } private func clearHoveredRowAndRestoreSelectionHighlight() { - cancelPageHighlightTask() clearHoveredRow() pageHighlightIntent = .restoreSelectionAfterHover + let operation = PageHighlightOperation.restoreSelectionAfterHover + guard pageHighlightOperation != operation else { + return + } + cancelPageHighlightTask(preservingIntent: true) + let taskID = UUID() + pageHighlightTaskID = taskID + pageHighlightOperation = operation pageHighlightTask = Task { @MainActor [weak self, restoreHighlightAction] in await Task.yield() guard !Task.isCancelled, let self else { return } + defer { + self.finishPageHighlightTask(id: taskID) + } guard self.hoveredNodeID == nil else { - if self.pageHighlightIntent == .restoreSelectionAfterHover { + if self.pageHighlightTaskID == taskID, + self.pageHighlightIntent == .restoreSelectionAfterHover { self.pageHighlightIntent = nil } return @@ -1323,7 +1356,8 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat if let restoreHighlightAction { try await restoreHighlightAction() } - if self.pageHighlightIntent == .restoreSelectionAfterHover { + if self.pageHighlightTaskID == taskID, + self.pageHighlightIntent == .restoreSelectionAfterHover { self.pageHighlightIntent = nil } } catch { @@ -1335,11 +1369,22 @@ final class DOMTreeTextView: UIScrollView, UITextInput, UITextInteractionDelegat private func cancelPageHighlightTask(preservingIntent: Bool = false) { pageHighlightTask?.cancel() pageHighlightTask = nil + pageHighlightTaskID = nil + pageHighlightOperation = nil if !preservingIntent { pageHighlightIntent = nil } } + private func finishPageHighlightTask(id: UUID) { + guard pageHighlightTaskID == id else { + return + } + pageHighlightTask = nil + pageHighlightTaskID = nil + pageHighlightOperation = nil + } + private func presentDOMMenu(for nodeIDs: [DOMNode.ID], at location: CGPoint) { let menu = makeDOMMenu(for: nodeIDs) diff --git a/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift b/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift index c8f2d5d3..c0e2764f 100644 --- a/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift +++ b/Sources/WebInspectorUIDOM/Tree/DOMTreeViewController.swift @@ -11,7 +11,7 @@ package final class DOMTreeViewController: UIViewController { treeView.undoManager } - package init(context: WebInspectorContext) { + package init(context: WebInspectorModelContext) { self.treeView = DOMTreeTextView( context: context, requestChildrenAction: { [weak context] nodeID in @@ -19,7 +19,10 @@ package final class DOMTreeViewController: UIViewController { return false } do { - try await context.dom.requestChildren(of: nodeID) + guard let node = try context.domNode(id: nodeID) else { + return false + } + try await context.requestDOMChildren(of: node) return true } catch { WebInspectorUIDOMLog.debug("DOM tree request children failed nodeID=\(String(describing: nodeID)): \(String(describing: error))") @@ -30,16 +33,19 @@ package final class DOMTreeViewController: UIViewController { guard let context else { return } - try await context.dom.highlight(nodeID) + guard let node = try context.domNode(id: nodeID) else { + return + } + try await context.highlightDOMNode(node) }, restoreHighlightAction: { [weak context] in guard let context else { return } - if let selectedNode = context.selectedNode { - try await context.dom.highlight(selectedNode.id) + if let selectedNode = try context.selectedDOMNode { + try await context.highlightDOMNode(selectedNode) } else { - try await context.dom.hideHighlight() + try await context.hideDOMHighlight() } }, copyNodeTextAction: { [weak context] nodeID, kind in @@ -47,7 +53,10 @@ package final class DOMTreeViewController: UIViewController { return nil } do { - return try await context.copyText(kind, for: nodeID) + guard let node = try context.domNode(id: nodeID) else { + return nil + } + return try await context.copyText(kind, for: node) } catch { WebInspectorUIDOMLog.debug("DOM tree copy text failed nodeID=\(String(describing: nodeID)): \(String(describing: error))") return nil @@ -94,35 +103,30 @@ package final class DOMTreeViewController: UIViewController { private static func deleteNodeIDs( _ nodeIDs: [DOMNode.ID], - context: WebInspectorContext, + context: WebInspectorModelContext, undoManager: UndoManager? ) async -> Bool { - let deletedNodeCount: Int - let undoCommands: WebInspectorContext.DOMUndoRedoCommands do { - let result = try await context.dom.remove(nodeIDs) - deletedNodeCount = result.acceptedNodeIDs.count - undoCommands = try context.domUndoRedoCommands() - } catch let error as WebInspectorContext.DOMDeletionPartialFailure { - guard let partialUndoCommands = try? context.domUndoRedoCommands() else { + let nodes = try nodeIDs.map { id in + guard let node = try context.domNode(id: id) else { + throw WebInspectorModelError.staleModel + } + return node + } + let result = try await context.removeDOMNodes(nodes) + guard let undo = result.undo else { return false } DOMDeletionUndoRegistration.registerDeleteUndo( on: undoManager, - commands: partialUndoCommands, - deletedNodeCount: error.deletedNodeCount + capability: undo, + deletedNodeCount: result.appliedNodeIDs.count ) - return error.deletedNodeCount > 0 + return result.appliedNodeIDs.isEmpty == false } catch { WebInspectorUIDOMLog.debug("DOM tree delete failed nodeIDs=\(nodeIDs.map { String(describing: $0) }): \(String(describing: error))") return false } - DOMDeletionUndoRegistration.registerDeleteUndo( - on: undoManager, - commands: undoCommands, - deletedNodeCount: deletedNodeCount - ) - return deletedNodeCount > 0 } override package func viewIsAppearing(_ animated: Bool) { @@ -151,7 +155,7 @@ extension DOMTreeViewController { @MainActor private enum DOMTreeViewControllerPreview { static func makeViewController() -> UINavigationController { - let viewController = DOMTreeViewController(context: DOMPreviewFixtures.makeWebInspectorContext()) + let viewController = DOMTreeViewController(context: DOMPreviewFixtures.makeWebInspectorModelContext()) return UINavigationController(rootViewController: viewController) } } diff --git a/Sources/WebInspectorUIDOM/WebInspectorUIDOMLog.swift b/Sources/WebInspectorUIDOM/WebInspectorUIDOMLog.swift index e3b500ec..51fc4c6e 100644 --- a/Sources/WebInspectorUIDOM/WebInspectorUIDOMLog.swift +++ b/Sources/WebInspectorUIDOM/WebInspectorUIDOMLog.swift @@ -10,4 +10,8 @@ enum WebInspectorUIDOMLog { static func debug(_ message: String) { logger.debug("\(message, privacy: .public)") } + + static func error(_ message: String) { + logger.error("\(message, privacy: .public)") + } } diff --git a/Sources/WebInspectorUINetwork/Containers/NetworkSplitViewController.swift b/Sources/WebInspectorUINetwork/Containers/NetworkSplitViewController.swift index edda3f9d..1b2675d4 100644 --- a/Sources/WebInspectorUINetwork/Containers/NetworkSplitViewController.swift +++ b/Sources/WebInspectorUINetwork/Containers/NetworkSplitViewController.swift @@ -94,16 +94,13 @@ private final class NetworkListColumnNavigationController: UINavigationControlle } #Preview("Network Split") { - let model = NetworkPreviewFixtures.makePanelModel(mode: .detail) - NetworkCompactNavigationController( - model: model, - listViewController: NetworkListViewController( - model: model - ), - detailViewController: NetworkDetailViewController( - model: model + NetworkPreviewFixtures.makeViewController(mode: .detail) { model in + NetworkCompactNavigationController( + model: model, + listViewController: NetworkListViewController(model: model), + detailViewController: NetworkDetailViewController(model: model) ) - ) + } } #Preview("Network Split Log Preview") { @@ -117,21 +114,20 @@ private final class NetworkListColumnNavigationController: UINavigationControlle private func makeNetworkSplitPreviewController( initialMode: NetworkDetailViewController.Mode = .headers, selectedDisplayName: String? = nil -) -> NetworkCompactNavigationController { - let model = NetworkPreviewFixtures.makePanelModel(mode: .detail) - if let selectedDisplayName, - let request = model.displayRequests.first(where: { $0.displayName == selectedDisplayName }) { - model.selectRequest(request) - } - return NetworkCompactNavigationController( - model: model, - listViewController: NetworkListViewController( - model: model - ), - detailViewController: NetworkDetailViewController( +) -> UIViewController { + NetworkPreviewFixtures.makeViewController(mode: .detail) { model in + if let selectedDisplayName, + let request = model.displayRequests.first(where: { $0.displayName == selectedDisplayName }) { + model.selectRequest(request) + } + return NetworkCompactNavigationController( model: model, - initialMode: initialMode + listViewController: NetworkListViewController(model: model), + detailViewController: NetworkDetailViewController( + model: model, + initialMode: initialMode + ) ) - ) + } } #endif diff --git a/Sources/WebInspectorUINetwork/Detail/NetworkDetailViewController.swift b/Sources/WebInspectorUINetwork/Detail/NetworkDetailViewController.swift index b66215e3..a4c25ab1 100644 --- a/Sources/WebInspectorUINetwork/Detail/NetworkDetailViewController.swift +++ b/Sources/WebInspectorUINetwork/Detail/NetworkDetailViewController.swift @@ -672,46 +672,54 @@ extension NetworkDetailViewController { #endif #Preview("Network Detail") { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .detail) + NetworkPreviewFixtures.makeViewController(mode: .detail) { model in + UINavigationController( + rootViewController: NetworkDetailViewController(model: model) ) - ) + } } #Preview("Network Detail Preview Response Only Short") { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .detailResponseOnlyShort), - initialMode: .preview + NetworkPreviewFixtures.makeViewController(mode: .detailResponseOnlyShort) { model in + UINavigationController( + rootViewController: NetworkDetailViewController( + model: model, + initialMode: .preview + ) ) - ) + } } #Preview("Network Detail Preview Request and Response Short") { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .detailRequestAndResponseShort), - initialMode: .preview + NetworkPreviewFixtures.makeViewController(mode: .detailRequestAndResponseShort) { model in + UINavigationController( + rootViewController: NetworkDetailViewController( + model: model, + initialMode: .preview + ) ) - ) + } } #Preview("Network Detail Preview Response Only Long") { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .detailResponseOnlyLong), - initialMode: .preview + NetworkPreviewFixtures.makeViewController(mode: .detailResponseOnlyLong) { model in + UINavigationController( + rootViewController: NetworkDetailViewController( + model: model, + initialMode: .preview + ) ) - ) + } } #Preview("Network Detail Preview Request and Response Long") { - UINavigationController( - rootViewController: NetworkDetailViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .detailRequestAndResponseLong), - initialMode: .preview + NetworkPreviewFixtures.makeViewController(mode: .detailRequestAndResponseLong) { model in + UINavigationController( + rootViewController: NetworkDetailViewController( + model: model, + initialMode: .preview + ) ) - ) + } } #endif diff --git a/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift b/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift index 8410ebc6..7ee2d0c3 100644 --- a/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift +++ b/Sources/WebInspectorUINetwork/List/NetworkListViewController.swift @@ -46,9 +46,10 @@ package final class NetworkListViewController: UICollectionViewController, UISea } private let model: NetworkPanelModel - private let fetchedResultsController: WebInspectorFetchedResultsController + private let fetchedResults: WebInspectorFetchedResults private var requestSelectionAction: RequestSelectionAction - private var fetchedResultsTransactionTask: Task? + private var fetchedResultsUpdateTask: Task? + private var lastFetchedResultsRevision: UInt64? private var searchTextObservation: PortableObservationTracking.Token? private var resourceFilterObservation: PortableObservationTracking.Token? private var selectedRequestObservation: PortableObservationTracking.Token? @@ -57,7 +58,7 @@ package final class NetworkListViewController: UICollectionViewController, UISea private var isApplyingSearchPresentation = false private var activeSearchController: UISearchController? #if DEBUG - private struct FetchedResultsTransactionDeliveryWaiter { + private struct FetchedResultsUpdateDeliveryWaiter { var id: Int var baselineCount: Int var continuation: CheckedContinuation @@ -66,9 +67,9 @@ package final class NetworkListViewController: UICollectionViewController, UISea private var deinitHandlerForTesting: (@MainActor () -> Void)? private var snapshotUpdateCompletionWaitersForTesting: [CheckedContinuation] = [] - private var fetchedResultsTransactionDeliveryWaitersForTesting: [FetchedResultsTransactionDeliveryWaiter] = [] - private var fetchedResultsTransactionDeliveryWaiterIDStorageForTesting = 0 - private var fetchedResultsTransactionDeliveryCountStorageForTesting = 0 + private var fetchedResultsUpdateDeliveryWaitersForTesting: [FetchedResultsUpdateDeliveryWaiter] = [] + private var fetchedResultsUpdateDeliveryWaiterIDStorageForTesting = 0 + private var fetchedResultsUpdateDeliveryCountStorageForTesting = 0 private var displayRequestIDsEvaluationCountStorageForTesting = 0 private var snapshotApplyCountStorageForTesting = 0 private var filterMenuBuildCountStorageForTesting = 0 @@ -92,7 +93,7 @@ package final class NetworkListViewController: UICollectionViewController, UISea package init(model: NetworkPanelModel) { self.model = model - self.fetchedResultsController = WebInspectorFetchedResultsController(fetchedResults: model.requests) + fetchedResults = model.requests requestSelectionAction = { [model] request in model.selectRequest(request) } @@ -106,13 +107,13 @@ package final class NetworkListViewController: UICollectionViewController, UISea } isolated deinit { - fetchedResultsTransactionTask?.cancel() + fetchedResultsUpdateTask?.cancel() searchTextObservation?.cancel() resourceFilterObservation?.cancel() selectedRequestObservation?.cancel() detachSearchPresentation() #if DEBUG - resolveFetchedResultsTransactionDeliveryWaitersForTesting(result: false) + resolveFetchedResultsUpdateDeliveryWaitersForTesting(result: false) deinitHandlerForTesting?() #endif } @@ -176,7 +177,7 @@ package final class NetworkListViewController: UICollectionViewController, UISea } private func startObservingModel() { - startObservingFetchedResultsTransactions() + startObservingFetchedResultsUpdates() searchTextObservation?.cancel() searchTextObservation = withPortableContinuousObservation { [weak self] _ in @@ -203,12 +204,16 @@ package final class NetworkListViewController: UICollectionViewController, UISea } } - private func startObservingFetchedResultsTransactions() { - fetchedResultsTransactionTask?.cancel() - let transactions = fetchedResultsController.transactions - fetchedResultsTransactionTask = Task { @MainActor [weak self] in - for await transaction in transactions { - self?.fetchedResultsDidPublish(transaction) + private func startObservingFetchedResultsUpdates() { + fetchedResultsUpdateTask?.cancel() + let updates = fetchedResults.updates() + // Initial appearance synchronously reloads from fetchedResults. Treat + // the subscription's current revision as the delivery baseline so the + // queued `.initial` cannot schedule a redundant later reload. + lastFetchedResultsRevision = fetchedResults.revision + fetchedResultsUpdateTask = Task { @MainActor [weak self] in + for await update in updates { + self?.fetchedResultsDidPublish(update) } } } @@ -483,23 +488,59 @@ package final class NetworkListViewController: UICollectionViewController, UISea } private func fetchedResultsDidPublish( - _ transaction: WebInspectorFetchedResultsTransaction + _ update: WebInspectorFetchedResultsUpdate ) { - guard transaction.hasNetworkListTopologyChanges else { - return - } + switch update { + case .initial(let revision, let snapshot): + guard lastFetchedResultsRevision != revision else { + return + } + lastFetchedResultsRevision = revision #if DEBUG - recordFetchedResultsTransactionDeliveryForTesting() + recordFetchedResultsUpdateDeliveryForTesting() #endif - guard snapshotCoordinator.isRenderingActive else { - snapshotCoordinator.markNeedsReloadOnNextAppearance() - return + guard snapshotCoordinator.isRenderingActive else { + if isViewLoaded == false + || dataSource.snapshot().itemIdentifiers != snapshot.itemIDs { + snapshotCoordinator.markNeedsReloadOnNextAppearance() + } + return + } + requestSnapshotUpdate(requestIDs: snapshot.itemIDs) + renderEmptyState(isEmpty: snapshot.itemIDs.isEmpty) + + case .transaction(let revision, let transaction, _): + // NetworkListCell observes each stable NetworkRequest identity + // directly, so this consumer only applies collection topology. + let isContiguous = lastFetchedResultsRevision.map { previousRevision in + revision == previousRevision &+ 1 + } ?? false + lastFetchedResultsRevision = revision +#if DEBUG + recordFetchedResultsUpdateDeliveryForTesting() +#endif + guard isContiguous else { + guard snapshotCoordinator.isRenderingActive else { + snapshotCoordinator.markNeedsReloadOnNextAppearance() + return + } + requestSnapshotUpdate(requestIDs: transaction.newSnapshot.itemIDs) + renderEmptyState(isEmpty: transaction.newSnapshot.itemIDs.isEmpty) + return + } + guard transaction.hasNetworkListTopologyChanges else { + return + } + guard snapshotCoordinator.isRenderingActive else { + snapshotCoordinator.markNeedsReloadOnNextAppearance() + return + } + applyTopologyTransaction(transaction) } - applyTopologyTransaction(transaction) } private func applyTopologyTransaction( - _ transaction: WebInspectorFetchedResultsTransaction + _ transaction: WebInspectorFetchedResultsTransaction ) { let requestIDs = transaction.newSnapshot.itemIDs let topologyItemChanges = transaction.networkListTopologyItemChanges @@ -715,7 +756,7 @@ package final class NetworkListViewController: UICollectionViewController, UISea } } -private extension WebInspectorFetchedResultsTransaction where Model == NetworkRequest { +private extension WebInspectorFetchedResultsTransaction where ItemID == NetworkRequest.ID { var hasNetworkListTopologyChanges: Bool { isReset || sectionChanges.isEmpty == false || networkListTopologyItemChanges.isEmpty == false } @@ -761,8 +802,8 @@ extension NetworkListViewController { snapshotApplyCountStorageForTesting } - package var fetchedResultsTransactionDeliveryCountForTesting: Int { - fetchedResultsTransactionDeliveryCountStorageForTesting + package var fetchedResultsUpdateDeliveryCountForTesting: Int { + fetchedResultsUpdateDeliveryCountStorageForTesting } package var filterMenuBuildCountForTesting: Int { @@ -813,25 +854,25 @@ extension NetworkListViewController { await waitForSnapshotUpdateCompletionForTesting() } - package func waitForFetchedResultsTransactionDeliveryForTesting( + package func waitForFetchedResultsUpdateDeliveryForTesting( after baselineCount: Int, timeout: Duration = .seconds(1) ) async -> Bool { - guard fetchedResultsTransactionDeliveryCountStorageForTesting <= baselineCount else { + guard fetchedResultsUpdateDeliveryCountStorageForTesting <= baselineCount else { return true } return await withCheckedContinuation { continuation in - let waiterID = fetchedResultsTransactionDeliveryWaiterIDStorageForTesting - fetchedResultsTransactionDeliveryWaiterIDStorageForTesting &+= 1 + let waiterID = fetchedResultsUpdateDeliveryWaiterIDStorageForTesting + fetchedResultsUpdateDeliveryWaiterIDStorageForTesting &+= 1 let timeoutTask = Task { @MainActor [weak self] in try? await Task.sleep(for: timeout) - self?.resolveFetchedResultsTransactionDeliveryWaiterForTesting( + self?.resolveFetchedResultsUpdateDeliveryWaiterForTesting( id: waiterID, result: false ) } - fetchedResultsTransactionDeliveryWaitersForTesting.append( - FetchedResultsTransactionDeliveryWaiter( + fetchedResultsUpdateDeliveryWaitersForTesting.append( + FetchedResultsUpdateDeliveryWaiter( id: waiterID, baselineCount: baselineCount, continuation: continuation, @@ -862,28 +903,28 @@ extension NetworkListViewController { } } - private func recordFetchedResultsTransactionDeliveryForTesting() { - fetchedResultsTransactionDeliveryCountStorageForTesting &+= 1 - resolveFetchedResultsTransactionDeliveryWaitersForTesting(result: true) + private func recordFetchedResultsUpdateDeliveryForTesting() { + fetchedResultsUpdateDeliveryCountStorageForTesting &+= 1 + resolveFetchedResultsUpdateDeliveryWaitersForTesting(result: true) } - private func resolveFetchedResultsTransactionDeliveryWaitersForTesting(result: Bool) { - let waiterIDs = fetchedResultsTransactionDeliveryWaitersForTesting.compactMap { waiter in - if result == false || fetchedResultsTransactionDeliveryCountStorageForTesting > waiter.baselineCount { + private func resolveFetchedResultsUpdateDeliveryWaitersForTesting(result: Bool) { + let waiterIDs = fetchedResultsUpdateDeliveryWaitersForTesting.compactMap { waiter in + if result == false || fetchedResultsUpdateDeliveryCountStorageForTesting > waiter.baselineCount { return waiter.id } return nil } for waiterID in waiterIDs { - resolveFetchedResultsTransactionDeliveryWaiterForTesting(id: waiterID, result: result) + resolveFetchedResultsUpdateDeliveryWaiterForTesting(id: waiterID, result: result) } } - private func resolveFetchedResultsTransactionDeliveryWaiterForTesting(id: Int, result: Bool) { - guard let index = fetchedResultsTransactionDeliveryWaitersForTesting.firstIndex(where: { $0.id == id }) else { + private func resolveFetchedResultsUpdateDeliveryWaiterForTesting(id: Int, result: Bool) { + guard let index = fetchedResultsUpdateDeliveryWaitersForTesting.firstIndex(where: { $0.id == id }) else { return } - let waiter = fetchedResultsTransactionDeliveryWaitersForTesting.remove(at: index) + let waiter = fetchedResultsUpdateDeliveryWaitersForTesting.remove(at: index) waiter.timeoutTask.cancel() waiter.continuation.resume(returning: result) } @@ -895,18 +936,18 @@ extension NetworkListViewController { #endif #Preview("Network List") { - UINavigationController( - rootViewController: NetworkListViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .root) + NetworkPreviewFixtures.makeViewController(mode: .root) { model in + UINavigationController( + rootViewController: NetworkListViewController(model: model) ) - ) + } } #Preview("Network List Long Title") { - UINavigationController( - rootViewController: NetworkListViewController( - model: NetworkPreviewFixtures.makePanelModel(mode: .rootLongTitle) + NetworkPreviewFixtures.makeViewController(mode: .rootLongTitle) { model in + UINavigationController( + rootViewController: NetworkListViewController(model: model) ) - ) + } } #endif diff --git a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift index d66d8375..89a9c1f1 100644 --- a/Sources/WebInspectorUINetwork/NetworkPanelModel.swift +++ b/Sources/WebInspectorUINetwork/NetworkPanelModel.swift @@ -1,7 +1,7 @@ -import WebInspectorUIBase -import WebInspectorDataKit import Foundation import Observation +import WebInspectorDataKit +import WebInspectorUIBase @MainActor private final class NetworkResponseBodyFetchCoordinator { @@ -9,7 +9,10 @@ private final class NetworkResponseBodyFetchCoordinator { init() {} - func fetchIfNeeded(for request: NetworkRequest) { + func fetchIfNeeded( + for request: NetworkRequest, + context: WebInspectorModelContext + ) { guard request.canFetchResponseBody, fetchesInFlight.contains(request.id) == false else { return @@ -19,7 +22,11 @@ private final class NetworkResponseBodyFetchCoordinator { defer { fetchesInFlight.remove(request.id) } - await request.fetchResponseBody() + do { + _ = try await context.responseBody(for: request) + } catch { + return + } } } } @@ -27,28 +34,55 @@ private final class NetworkResponseBodyFetchCoordinator { @MainActor @Observable package final class NetworkPanelModel { - package let context: WebInspectorContext + private enum Lifecycle { + case active + case retiring(Task?) + case retired + } + + package let context: WebInspectorModelContext package let requests: WebInspectorFetchedResults private let collectionState: NetworkRequestCollectionState - package var selectedRequestID: NetworkRequest.ID? - package var searchText: String = "" - package var activeResourceFilters: Set = [] { - didSet { - let normalized = NetworkDisplay.ResourceFilter.normalizedSelection(activeResourceFilters) - if effectiveResourceFilters != normalized { - effectiveResourceFilters = normalized - updateNetworkFetchDescriptor() - } - } - } - package private(set) var effectiveResourceFilters: Set = [] + package private(set) var selectedRequestID: NetworkRequest.ID? + package private(set) var searchText: String + package private(set) var activeResourceFilters: Set + package private(set) var query: NetworkQuery + package private(set) var queryRevision: UInt64 + package private(set) var appliedQueryRevision: UInt64 @ObservationIgnored private let responseBodyFetchCoordinator: NetworkResponseBodyFetchCoordinator + @ObservationIgnored private var queryUpdateTask: Task? + @ObservationIgnored private var queryUpdateTaskIsCommittedClear: Bool + @ObservationIgnored private var queryGeneration: UInt64 + @ObservationIgnored private var lifecycle: Lifecycle - package init(context: WebInspectorContext) { + private init( + context: WebInspectorModelContext, + requests: WebInspectorFetchedResults, + query: NetworkQuery + ) { self.context = context - self.requests = context.network.fetchedResults(for: Self.makeNetworkFetchDescriptor(searchText: "", filters: [])) + self.requests = requests self.collectionState = context.networkRequestsCollectionState + self.searchText = query.search ?? "" + self.activeResourceFilters = [] + self.query = query + self.queryRevision = 0 + self.appliedQueryRevision = 0 self.responseBodyFetchCoordinator = NetworkResponseBodyFetchCoordinator() + self.queryGeneration = 0 + self.queryUpdateTaskIsCommittedClear = false + self.lifecycle = .active + } + + /// Creates a ready Network panel after its atomic initial query snapshot is available. + package static func make(context: WebInspectorModelContext) async throws -> NetworkPanelModel { + let query = NetworkQuery(sort: .requestTimeDescending) + let requests = try await context.networkRequests(matching: query) + return NetworkPanelModel(context: context, requests: requests, query: query) + } + + isolated deinit { + synchronouslyCancelForOwnerDeinit() } package var displayRequestIDs: [NetworkRequest.ID] { @@ -67,6 +101,10 @@ package final class NetworkPanelModel { collectionState.hasRequests } + package var effectiveResourceFilters: Set { + NetworkDisplay.ResourceFilter.normalizedSelection(activeResourceFilters) + } + package var selectedRequest: NetworkRequest? { guard let selectedRequestID else { return nil @@ -78,22 +116,25 @@ package final class NetworkPanelModel { } package func request(for id: NetworkRequest.ID) -> NetworkRequest? { - context.registeredRequest(for: id) + try? context.networkRequest(id: id) } package func selectRequest(_ request: NetworkRequest?) { + requireActive() selectedRequestID = request?.id } package func setSearchText(_ text: String) { + requireActive() guard searchText != text else { return } searchText = text - updateNetworkFetchDescriptor() + scheduleQueryUpdate() } package func setResourceFilter(_ filter: NetworkDisplay.ResourceFilter, enabled: Bool) { + requireActive() var nextFilters = activeResourceFilters if enabled { nextFilters.insert(filter) @@ -105,63 +146,182 @@ package final class NetworkPanelModel { return } activeResourceFilters = nextFilters + scheduleQueryUpdate() } package func clearResourceFilters() { + requireActive() guard activeResourceFilters.isEmpty == false else { return } activeResourceFilters = [] + scheduleQueryUpdate() } package func clearRequests() { + requireActive() selectedRequestID = nil - context.network.clearRequests() + precondition(queryGeneration < UInt64.max, "Network panel operation generation overflowed.") + queryGeneration += 1 + let generation = queryGeneration + let revision = queryRevision + let query = query + let previousTask = queryUpdateTask + if queryUpdateTaskIsCommittedClear == false { + previousTask?.cancel() + } + queryUpdateTaskIsCommittedClear = true + let context = context + let requests = requests + queryUpdateTask = Task { @MainActor [weak self] in + await previousTask?.value + guard Task.isCancelled == false else { + return + } + // Clear is a committed user operation, not a query candidate. Once + // scheduled it completes even if a later query supersedes this task. + await context.clearNetworkRequests() + guard self?.isActiveQueryGeneration(generation) == true else { + return + } + guard self?.appliedQueryRevision != revision else { + return + } + do { + try await requests.update(query) + } catch is CancellationError { + return + } catch { + preconditionFailure("Network query restoration after clear failed: \(error)") + } + guard let self, + isActiveQueryGeneration(generation) else { + return + } + appliedQueryRevision = revision + } } package func fetchResponseBodyIfNeeded(for request: NetworkRequest) { - responseBodyFetchCoordinator.fetchIfNeeded(for: request) + requireActive() + responseBodyFetchCoordinator.fetchIfNeeded(for: request, context: context) } - private var normalizedSearchText: String { - searchText.trimmingCharacters(in: .whitespacesAndNewlines) + /// Cancels and awaits the current query replacement before releasing this owner. + package func retire() async { + switch lifecycle { + case .active: + let task = queryUpdateTask + queryUpdateTask = nil + task?.cancel() + lifecycle = .retiring(task) + await task?.value + lifecycle = .retired + case let .retiring(task): + await task?.value + lifecycle = .retired + case .retired: + return + } } - private func updateNetworkFetchDescriptor() { - requests.updateFetchDescriptor( - Self.makeNetworkFetchDescriptor( - searchText: normalizedSearchText, - filters: effectiveResourceFilters - ) - ) + /// Synchronous backstop used only when the presentation resource owner is + /// itself deinitializing and can no longer await ``retire()``. + package func synchronouslyCancelForOwnerDeinit() { + queryUpdateTask?.cancel() + queryUpdateTask = nil + queryUpdateTaskIsCommittedClear = false + if case let .retiring(task) = lifecycle { + task?.cancel() + } + lifecycle = .retired } - private static func makeNetworkFetchDescriptor( - searchText: String, - filters: Set - ) -> WebInspectorFetchDescriptor { - let categories = NetworkRequest.ResourceCategory.networkCategories(for: filters) - let normalizedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - let predicate: Predicate? - if normalizedSearchText.isEmpty, categories.isEmpty { - predicate = nil - } else if categories.isEmpty { - predicate = #Predicate { request in - request.searchableText.localizedStandardContains(normalizedSearchText) + /// Waits until the latest scheduled query replacement reaches a terminal state. + package func waitForQueryUpdates() async { + while true { + let generation = queryGeneration + let task = queryUpdateTask + await task?.value + if generation == queryGeneration { + return } - } else if normalizedSearchText.isEmpty { - predicate = #Predicate { request in - categories.contains(request.resourceCategory) + } + } + + private func scheduleQueryUpdate() { + let nextQuery = Self.makeNetworkQuery( + searchText: searchText, + filters: effectiveResourceFilters + ) + guard query != nextQuery else { + return + } + + precondition(queryGeneration < UInt64.max, "Network panel query generation overflowed.") + precondition(queryRevision < UInt64.max, "Network panel query revision overflowed.") + queryGeneration += 1 + queryRevision += 1 + query = nextQuery + + let generation = queryGeneration + let revision = queryRevision + let previousTask = queryUpdateTask + if queryUpdateTaskIsCommittedClear == false { + previousTask?.cancel() + } + queryUpdateTaskIsCommittedClear = false + let requests = requests + queryUpdateTask = Task { @MainActor [weak self] in + await previousTask?.value + guard self?.isActiveQueryGeneration(generation) == true else { + return } - } else { - predicate = #Predicate { request in - categories.contains(request.resourceCategory) - && request.searchableText.localizedStandardContains(normalizedSearchText) + do { + try await requests.update(nextQuery) + } catch is CancellationError { + return + } catch { + preconditionFailure("Network query replacement failed: \(error)") + } + guard let self, + isActiveQueryGeneration(generation) else { + return } + appliedQueryRevision = revision } - return WebInspectorFetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .reverse)] + } + + private func requireActive() { + guard case .active = lifecycle else { + preconditionFailure("A retired NetworkPanelModel cannot accept new work.") + } + } + + private func isActiveQueryGeneration(_ generation: UInt64) -> Bool { + guard case .active = lifecycle else { + return false + } + return queryGeneration == generation + } + + #if DEBUG + package var isRetiredForTesting: Bool { + if case .retired = lifecycle { + return true + } + return false + } + #endif + + private static func makeNetworkQuery( + searchText: String, + filters: Set + ) -> NetworkQuery { + NetworkQuery( + search: searchText, + resourceCategories: NetworkRequest.ResourceCategory.networkCategories(for: filters), + sort: .requestTimeDescending ) } } @@ -169,10 +329,10 @@ package final class NetworkPanelModel { private extension NetworkRequest.ResourceCategory { static func networkCategories( for filters: Set - ) -> [NetworkRequest.ResourceCategory] { - var categories: [NetworkRequest.ResourceCategory] = [] + ) -> Set { + var categories: Set = [] for filter in NetworkDisplay.ResourceFilter.pickerCases where filters.contains(filter) { - categories.append(contentsOf: filter.networkResourceCategories) + categories.formUnion(filter.networkResourceCategories) } return categories } diff --git a/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift b/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift index cbfaf7ef..2a145b64 100644 --- a/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift +++ b/Sources/WebInspectorUINetwork/NetworkPreviewFixtures.swift @@ -1,3 +1,6 @@ +#if canImport(UIKit) +import UIKit +#endif import WebInspectorDataKit import WebInspectorUIBase @@ -13,9 +16,9 @@ package enum NetworkPreviewFixtures { case detailRequestAndResponseLong } - package static func makePanelModel(mode: Mode) -> NetworkPanelModel { + package static func makePanelModel(mode: Mode) async throws -> NetworkPanelModel { let context = makeContext(mode: mode) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) switch mode { case .detail, .detailResponseOnlyShort, @@ -29,13 +32,25 @@ package enum NetworkPreviewFixtures { return model } - package static func makeContext(mode: Mode) -> WebInspectorContext { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + #if canImport(UIKit) + package static func makeViewController( + mode: Mode, + makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController + ) -> UIViewController { + NetworkPreviewResourceViewController( + mode: mode, + makeReadyViewController: makeReadyViewController + ) + } + #endif + + package static func makeContext(mode: Mode) -> WebInspectorModelContext { + let context = WebInspectorModelContext.preview() applySampleData(to: context, mode: mode) return context } - package static func applySampleData(to context: WebInspectorContext, mode: Mode) { + package static func applySampleData(to context: WebInspectorModelContext, mode: Mode) { switch mode { case .detailResponseOnlyShort: applyRequest( @@ -146,7 +161,7 @@ package enum NetworkPreviewFixtures { @discardableResult private static func applyRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID: String, url: String, method: String = "GET", @@ -197,3 +212,61 @@ package enum NetworkPreviewFixtures { return #"{"kind":"\#(kind)","result":"ok","items":[\#(items)],"metadata":{"source":"preview","count":24}}"# } } + +#if canImport(UIKit) +@MainActor +private final class NetworkPreviewResourceViewController: UIViewController { + private var loadTask: Task? + + init( + mode: NetworkPreviewFixtures.Mode, + makeReadyViewController: @escaping @MainActor (NetworkPanelModel) -> UIViewController + ) { + super.init(nibName: nil, bundle: nil) + contentUnavailableConfiguration = UIContentUnavailableConfiguration.loading() + loadTask = Task { @MainActor [weak self] in + do { + let model = try await NetworkPreviewFixtures.makePanelModel(mode: mode) + guard let self, Task.isCancelled == false else { + await model.retire() + return + } + install(makeReadyViewController(model)) + loadTask = nil + } catch { + guard let self else { + return + } + var configuration = UIContentUnavailableConfiguration.empty() + configuration.text = "Network Preview Unavailable" + configuration.secondaryText = error.localizedDescription + contentUnavailableConfiguration = configuration + loadTask = nil + } + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + isolated deinit { + loadTask?.cancel() + } + + private func install(_ viewController: UIViewController) { + contentUnavailableConfiguration = nil + addChild(viewController) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(viewController.view) + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewController.didMove(toParent: self) + } +} +#endif diff --git a/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift b/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift index 2a75cbb3..0f6c8275 100644 --- a/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift +++ b/Sources/WebInspectorUISyntaxBody/NetworkBodyViewController.swift @@ -349,14 +349,19 @@ package final class NetworkBodyViewController: UIViewController, NetworkBodyPrev private func localizedDescription(for error: WebInspectorProxyError) -> String { switch error { - case .closed: + case .closed, .pageUnavailable, .staleIdentifier, .connectionInUse: String(localized: "network.body.fetch.error.unavailable", bundle: WebInspectorUILocalization.bundle) case .unsupported(let messages): messages.joined(separator: "\n") case .attachFailed(let message), .disconnected(let message), - .commandFailed(_, _, let message): + .commandFailed(_, _, let message), + .commandRejected(_, let message), + .protocolViolation(let message), + .transportFailure(let message): message + case .eventBufferOverflow(let capacity): + "Web Inspector event buffer exceeded \(capacity) pending events." case .timeout(let domain, let method): "\(domain).\(method) timed out." } diff --git a/Tests/ConcurrencyFixtures/ModelContextCannotCrossActors.swift b/Tests/ConcurrencyFixtures/ModelContextCannotCrossActors.swift new file mode 100644 index 00000000..e52cd262 --- /dev/null +++ b/Tests/ConcurrencyFixtures/ModelContextCannotCrossActors.swift @@ -0,0 +1,9 @@ +import WebInspectorDataKit + +func requireSendable(_: Value) {} + +func illegallyTreatModelContextAsSendable( + _ context: WebInspectorModelContext +) { + requireSendable(context) +} diff --git a/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift new file mode 100644 index 00000000..1bd22183 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/ConsoleConcreteQueryTests.swift @@ -0,0 +1,408 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func consoleQueryProvidesClosedDefaults() { + let query = ConsoleQuery() + + #expect(query.levels.isEmpty) + #expect(query.sort == .insertionAscending) + #expect(query.section == nil) + #expect(query.offset == 0) + #expect(query.limit == nil) +} +@Test +func consoleConcreteQueryRegistrationIncludesTheRequiredMutationSequence() async throws { + let index = ConsoleMessageIndex() + let secondMutation = Task { + await index.replace(with: [], sequence: 2) + } + try await waitForConcreteQueryCondition { + await index.isMutationPendingForTesting(sequence: 2) + } + + let lifetime = WebInspectorQueryRegistrationLifetime() + let generation = lifetime.nextGeneration() + let registration = Task { + try await index.register( + id: WebInspectorQueryRegistrationID(rawValue: 11), + generation: generation, + query: ConsoleQuery(), + lifetime: lifetime, + minimumSequence: 2 + ) + } + try await waitForConcreteQueryCondition { + await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) + } + + _ = await index.replace(with: [], sequence: 1) + _ = await secondMutation.value + let projection = try await registration.value + + #expect(projection.sequence == 2) + #expect(await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) == false) + #expect(await index.queryRegistrationCountForTesting() == 1) +} + +@Test +func cancelledConsoleConcreteQueryRegistrationStopsWaitingWithoutTheMissingMutation() async throws { + var index: ConsoleMessageIndex? = ConsoleMessageIndex() + weak let weakIndex = index + let lifetime = WebInspectorQueryRegistrationLifetime() + let generation = lifetime.nextGeneration() + let registration = Task { [index] in + guard let index else { + throw CancellationError() + } + return try await index.register( + id: WebInspectorQueryRegistrationID(rawValue: 12), + generation: generation, + query: ConsoleQuery(), + lifetime: lifetime, + minimumSequence: 1 + ) + } + try await waitForConcreteQueryCondition { [weak index] in + guard let index else { + return false + } + return await index.isSequenceWaiterPendingForTesting(minimumSequence: 1) + } + + registration.cancel() + + await #expect(throws: CancellationError.self) { + try await registration.value + } + if let index { + #expect(await index.isSequenceWaiterPendingForTesting(minimumSequence: 1) == false) + #expect(await index.queryRegistrationCountForTesting() == 0) + } + index = nil + #expect(weakIndex == nil) +} + +@Test +func cancelledConsoleConcreteQueryReplacementStopsWaitingWithoutTheMissingMutation() async throws { + let index = ConsoleMessageIndex() + _ = await index.replace(with: [], sequence: 1) + let lifetime = WebInspectorQueryRegistrationLifetime() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 13) + let activeGeneration = lifetime.nextGeneration() + _ = try await index.register( + id: registrationID, + generation: activeGeneration, + query: ConsoleQuery(), + lifetime: lifetime, + minimumSequence: 1 + ) + + let cancelledGeneration = lifetime.nextGeneration() + let replacement = Task { + try await index.prepareReplacement( + id: registrationID, + generation: cancelledGeneration, + query: ConsoleQuery(sort: .insertionDescending), + minimumSequence: 2 + ) + } + try await waitForConcreteQueryCondition { + await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) + } + + replacement.cancel() + + await #expect(throws: CancellationError.self) { + try await replacement.value + } + #expect(await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) == false) + #expect(await index.commitReplacement( + id: registrationID, + generation: cancelledGeneration + ) == nil) +} + +@MainActor +@Test +func consoleConcreteQueryFiltersSortsSectionsAndWindowsCompactRecords() async throws { + let context = WebInspectorModelContext.preview() + let warning = makeIndexedConsoleMessage( + id: 0, + level: "warning", + text: "warning", + context: context + ) + let log = makeIndexedConsoleMessage( + id: 1, + level: "log", + text: "log", + context: context + ) + let error = makeIndexedConsoleMessage( + id: 2, + level: "error", + text: "error", + context: context + ) + let index = ConsoleMessageIndex() + _ = await index.replace( + with: [warning.input, log.input, error.input], + sequence: 1 + ) + let lifetime = WebInspectorQueryRegistrationLifetime() + let generation = lifetime.nextGeneration() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 10) + let projection = try await index.register( + id: registrationID, + generation: generation, + query: ConsoleQuery( + levels: [ + Console.Level(rawValue: "warning"), + Console.Level(rawValue: "error"), + ], + sort: .insertionDescending, + section: .level, + limit: 1 + ), + lifetime: lifetime, + minimumSequence: 1 + ) + + #expect(projection.snapshot.itemIDs == [error.id]) + #expect(projection.snapshot.sections.map(\.id.rawValue) == ["error"]) + + let sameIDWithNewSection = makeIndexedConsoleMessage( + id: 2, + level: "warning", + text: "updated error", + context: context + ) + let deliveries = await index.upsert(sameIDWithNewSection.input, sequence: 2) + let updated = try #require(deliveries.first) + #expect(updated.projection.snapshot.itemIDs == [error.id]) + #expect(updated.projection.snapshot.sections.map(\.id.rawValue) == ["warning"]) + #expect(updated.projection.reconfigureItemIDs == [error.id]) +} + +@MainActor +@Test +func consoleConcreteQueryPublishesUpdatesPartialDeletionAndReplacementAtomically() async throws { + let context = WebInspectorModelContext.preview() + let store = ConsoleMessageStore() + let warningTarget = WebInspectorTarget.ID("concrete-warning-target") + let logTarget = WebInspectorTarget.ID("concrete-log-target") + let errorTarget = WebInspectorTarget.ID("concrete-error-target") + await addConsoleMessage( + level: "warning", + text: "warning", + targetID: warningTarget, + store: store, + context: context + ) + await addConsoleMessage( + level: "log", + text: "log", + targetID: logTarget, + store: store, + context: context + ) + await addConsoleMessage( + level: "error", + text: "error", + targetID: errorTarget, + store: store, + context: context + ) + + let warningID = ConsoleMessage.ID(0) + let logID = ConsoleMessage.ID(1) + let errorID = ConsoleMessage.ID(2) + let registeredWarning = store.message(for: warningID) + let warningIdentity = try #require(registeredWarning) + let results = try await store.results( + matching: ConsoleQuery( + levels: [ + Console.Level(rawValue: "warning"), + Console.Level(rawValue: "error"), + ], + sort: .insertionDescending, + section: .level, + limit: 2 + ), + modelContext: context, + ) + #expect(results.items.map(\.id) == [errorID, warningID]) + #expect(results.sections.map(\.id.rawValue) == ["error", "warning"]) + #expect(results.items.last === warningIdentity) + var updates = results.updates().makeAsyncIterator() + guard case .initial? = await updates.next() else { + Issue.record("Expected an initial concrete Console query state.") + return + } + + _ = await store.apply( + .messageRepeatCountUpdated(count: 4, timestamp: 4), + targetID: warningTarget, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Repeat updates have no Runtime parameters.") }, + ) + #expect(results.items.last === warningIdentity) + #expect(results.items.last?.repeatCount == 4) + guard case let .transaction(_, repeatUpdate, repeatReconfigure)? = await updates.next() else { + Issue.record("Expected the concrete Console repeat transaction.") + return + } + #expect(repeatUpdate.newSnapshot == results.snapshot) + #expect(repeatReconfigure == [warningID]) + + _ = await store.apply( + .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: warningTarget, + modelContext: context, + registerRuntimeObject: { _ in fatalError("Clear events have no Runtime parameters.") }, + ) + #expect(results.items.map(\.id) == [errorID]) + guard case let .transaction(_, clear, _)? = await updates.next() else { + Issue.record("Expected the concrete Console clear transaction.") + return + } + #expect(clear.isReset) + + try await store.update( + ConsoleQuery( + levels: [Console.Level(rawValue: "log")], + sort: .insertionAscending, + offset: 0, + limit: 1 + ), + for: results, + ) + #expect(results.items.map(\.id) == [logID]) + #expect(results.sections.map(\.id) == [.defaultSection]) +} + +@MainActor +@Test +func consoleConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { + let context = WebInspectorModelContext.preview() + let store = ConsoleMessageStore() + let recordCount = 10_000 + for ordinal in 0..? = try await store.results( + matching: ConsoleQuery(), + modelContext: context, + ) + weak let weakResults = results + + let registrationCount = await store.concreteQueryRegistrationCountForTesting( + ) + #expect(registrationCount == 1) + results = nil + #expect(weakResults == nil) + let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( + ) + #expect(prunedRegistrationCount == 0) +} + +@MainActor +private func makeIndexedConsoleMessage( + id: Int, + level: String, + text: String, + context: WebInspectorModelContext +) -> (id: ConsoleMessage.ID, input: ConsoleMessageRecordInput) { + let modelID = ConsoleMessage.ID(id) + let message = ConsoleMessage( + id: modelID, + message: Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: level), + text: text + ), + parameters: [], + targetID: nil, + modelContext: context + ) + return (modelID, ConsoleMessageRecordInput(message: message, orderIndex: id)) +} + +@MainActor +private func addConsoleMessage( + level: String, + text: String, + targetID: WebInspectorTarget.ID?, + store: ConsoleMessageStore, + context: WebInspectorModelContext +) async { + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: level), + text: text + )), + targetID: targetID, + modelContext: context, + registerRuntimeObject: { _ in fatalError("The fixture has no Runtime parameters.") }, + ) +} diff --git a/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift new file mode 100644 index 00000000..730a811c --- /dev/null +++ b/Tests/WebInspectorDataKitTests/ConsoleMessageStoreTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +private actor StoreIsolationProbe { + func exercise() async throws -> (networkCount: Int, consoleTexts: [String]) { + let context = WebInspectorModelContext.preview() + let networkStore = NetworkRequestStore() + let networkID = Network.Request.ID("custom-actor-request") + await networkStore.apply( + .requestWillBeSent( + id: networkID, + request: Network.Request( + id: networkID, + url: "https://example.com/custom-actor", + method: "GET" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + modelContext: context + ) + + let consoleStore = ConsoleMessageStore() + let results = try await consoleStore.results( + matching: ConsoleQuery(), + modelContext: context + ) + _ = await consoleStore.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: "custom actor" + )), + targetID: nil, + modelContext: context, + registerRuntimeObject: { _ in + fatalError("The fixture has no Runtime parameters.") + } + ) + return ( + networkStore.collectionState.requestCount, + results.items.map(\.text) + ) + } +} + +@Test +func networkAndConsoleStoresInheritTheCallingActor() async throws { + let values = try await StoreIsolationProbe().exercise() + + #expect(values.networkCount == 1) + #expect(values.consoleTexts == ["custom actor"]) +} + +@MainActor +@Test +func consoleMessageStoreClearsOnlyTheAddressedTarget() async throws { + let context = WebInspectorModelContext.preview() + let store = ConsoleMessageStore() + let firstTarget = WebInspectorTarget.ID("first") + let secondTarget = WebInspectorTarget.ID("second") + let results = try await store.results( + matching: ConsoleQuery(), + modelContext: context + ) + + for (target, text) in [(firstTarget, "first"), (secondTarget, "second")] { + _ = await store.apply( + .messageAdded(Console.Message( + source: Console.Source(rawValue: "console-api"), + level: Console.Level(rawValue: "log"), + text: text + )), + targetID: target, + modelContext: context, + registerRuntimeObject: { _ in + fatalError("The fixture has no Runtime parameters.") + } + ) + } + #expect(results.items.map(\.text) == ["first", "second"]) + + let effects = await store.apply( + .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), + targetID: firstTarget, + modelContext: context, + registerRuntimeObject: { _ in + fatalError("Clear events have no Runtime parameters.") + } + ) + + #expect(effects.clearedAllMessages == false) + #expect(results.items.map(\.text) == ["second"]) +} diff --git a/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift new file mode 100644 index 00000000..61a0c168 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DOMStateStoreTests.swift @@ -0,0 +1,247 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func domStateStorePreservesNodeIdentityAcrossPayloadUpdates() throws { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let documentID = DOM.Node.ID("document") + let elementID = DOM.Node.ID("element") + + let applied = store.applyDocument( + DOM.Node( + id: documentID, + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: elementID, + nodeType: 1, + nodeName: "DIV", + localName: "div", + attributes: ["class": "before"] + ) + ] + ), + expectedEpoch: store.documentEpoch, + reason: .initialDocument, + modelContext: context, + ) + #expect(applied != nil) + let original = try #require(store.node(for: DOMNode.ID(elementID))) + + _ = store.apply( + .attributeModified(elementID, name: "class", value: "after"), + modelContext: context, + ) + + #expect(store.node(for: DOMNode.ID(elementID)) === original) + #expect(original.attributes["class"] == "after") +} +@MainActor +@Test +func domStateStoreDocumentResetAdvancesEpochAndClearsSemanticState() throws { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let childID = DOM.Node.ID("selected") + _ = store.applyDocument( + DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div")] + ), + expectedEpoch: store.documentEpoch, + reason: .initialDocument, + modelContext: context, + ) + let child = try #require(store.node(for: DOMNode.ID(childID))) + _ = store.select(child, reveal: .none) + _ = store.setElementPickerEnabled(true) + let previousEpoch = store.documentEpoch + + let effects = store.apply( + .documentUpdated, + modelContext: context, + ) + + #expect(store.documentEpoch == previousEpoch + 1) + #expect(store.rootNode == nil) + #expect(store.selectedNode == nil) + #expect(store.node(for: child.id) == nil) + #expect(store.isElementPickerEnabled == false) + #expect(effects.documentReset) + #expect(effects.shouldReloadDocument) +} + +@MainActor +@Test +func domStateStoreProjectsFrameDocumentUnderItsFrameOwner() throws { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let frameTargetID = WebInspectorTarget.ID("frame-target") + let frameID = FrameID("child-frame") + let iframeID = DOM.Node.ID("iframe") + let frameDocumentID = DOM.Node.ID("frame-document") + + _ = store.applyDocument( + DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: iframeID, + nodeType: 1, + nodeName: "IFRAME", + localName: "iframe", + frameID: frameID + ) + ] + ), + expectedEpoch: store.documentEpoch, + reason: .initialDocument, + modelContext: context, + ) + + let applied = store.applyFrameDocument( + DOM.Node( + id: frameDocumentID, + nodeType: 9, + nodeName: "#document", + frameID: frameID + ), + frameTargetID: frameTargetID, + expectedEpoch: store.documentEpoch, + modelContext: context, + ) + #expect(applied != nil) + + let scopedDocumentID = DOMNode.ID(DOM.Node.ID( + frameDocumentID.rawValue, + scopedToTargetRawValue: frameTargetID.rawValue + )) + let snapshot = store.currentTreeSnapshot() + #expect(snapshot.parent(of: scopedDocumentID) == DOMNode.ID(iframeID)) + #expect(snapshot.visibleChildren(of: DOMNode.ID(iframeID)).nodeIDs == [scopedDocumentID]) +} + +@MainActor +@Test +func domStateStorePublishesSelectionDeltaAndRevealFromOneMutation() async throws { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let parentID = DOM.Node.ID("parent") + let childID = DOM.Node.ID("child") + _ = store.applyDocument( + DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: parentID, + nodeType: 1, + nodeName: "DIV", + localName: "div", + children: [DOM.Node(id: childID, nodeType: 1, nodeName: "SPAN", localName: "span")] + ) + ] + ), + expectedEpoch: store.documentEpoch, + reason: .initialDocument, + modelContext: context, + ) + let controller = store.rootTreeController() + var updateIterator = controller.updates.makeAsyncIterator() + var revealIterator = controller.revealRequests.makeAsyncIterator() + guard case .snapshot? = await updateIterator.next() else { + Issue.record("Expected the atomic initial DOM snapshot.") + return + } + let child = try #require(store.node(for: DOMNode.ID(childID))) + + _ = store.select(child, reveal: .selectOnly) + + #expect(await updateIterator.next() == .delta(.selectionChanged(nodeID: child.id))) + #expect(await revealIterator.next() == DOMTreeRevealRequest( + nodeID: child.id, + ancestorNodeIDs: [DOMNode.ID(parentID), DOMNode.ID(DOM.Node.ID("document"))], + shouldSelect: true, + shouldScroll: false + )) +} + +@MainActor +@Test +func domStateStoreTreeSubscriptionBridgesInitialSnapshotAndNextDeltaAtomically() async throws { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let elementID = DOM.Node.ID("element") + _ = store.applyDocument( + DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div")] + ), + expectedEpoch: store.documentEpoch, + reason: .initialDocument, + modelContext: context, + ) + let controller = store.rootTreeController() + let initialRevision = controller.revision + var iterator = controller.updates.makeAsyncIterator() + + _ = store.apply( + .attributeModified(elementID, name: "class", value: "updated"), + modelContext: context, + ) + + guard case let .snapshot(snapshot, reason)? = await iterator.next() else { + Issue.record("Expected initial snapshot before the next delta.") + return + } + #expect(snapshot.revision == initialRevision) + #expect(snapshot.node(for: DOMNode.ID(elementID))?.attributes["class"] == nil) + #expect(reason == .initialDocument) + #expect(await iterator.next() == .delta(.nodeChanged(nodeID: DOMNode.ID(elementID)))) +} + +@MainActor +@Test +func domStateStoreRejectsDocumentFromStaleEpoch() { + let context = WebInspectorModelContext.preview() + let store = DOMStateStore() + let staleEpoch = store.documentEpoch + store.advanceDocumentEpoch() + _ = store.resetDocument() + + let applied = store.applyDocument( + DOM.Node(id: DOM.Node.ID("stale-document"), nodeType: 9, nodeName: "#document"), + expectedEpoch: staleEpoch, + reason: .documentUpdated, + modelContext: context, + ) + + #expect(applied == nil) + #expect(store.rootNode == nil) +} + +@MainActor +@Test +func domStateStoreKeepsOnlyWeakTreeRegistrations() { + let store = DOMStateStore() + weak var releasedController: DOMTreeController? + + do { + let controller = store.rootTreeController() + releasedController = controller + #expect(releasedController != nil) + } + + #expect(releasedController == nil) + _ = store.resetDocument() +} diff --git a/Tests/WebInspectorDataKitTests/DataKitRawCommandAssertions.swift b/Tests/WebInspectorDataKitTests/DataKitRawCommandAssertions.swift new file mode 100644 index 00000000..58e7399b --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DataKitRawCommandAssertions.swift @@ -0,0 +1,101 @@ +import Foundation +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +enum RawCommandAssertionError: Error, Equatable { + case missingParameter(String) + case invalidParameter(String) +} + +func commandStringParameter( + _ command: WebInspectorTestPeer.Command, + _ key: String +) throws -> String { + let value = try commandParameter(command, key) + if let value = value as? String { + return value + } + if let value = value as? NSNumber { + return value.stringValue + } + throw RawCommandAssertionError.invalidParameter(key) +} + +func commandIntegerParameter( + _ command: WebInspectorTestPeer.Command, + _ key: String +) throws -> Int { + let value = try commandParameter(command, key) + guard let number = value as? NSNumber else { + throw RawCommandAssertionError.invalidParameter(key) + } + return number.intValue +} + +func commandBooleanParameter( + _ command: WebInspectorTestPeer.Command, + _ key: String +) throws -> Bool { + let value = try commandParameter(command, key) + guard let number = value as? NSNumber else { + throw RawCommandAssertionError.invalidParameter(key) + } + return number.boolValue +} + +func commandNestedStringParameter( + _ command: WebInspectorTestPeer.Command, + object objectKey: String, + key: String +) throws -> String { + let objectValue = try commandParameter(command, objectKey) + guard let object = objectValue as? [String: Any], let value = object[key] else { + throw RawCommandAssertionError.missingParameter("\(objectKey).\(key)") + } + if let value = value as? String { + return value + } + if let value = value as? NSNumber { + return value.stringValue + } + throw RawCommandAssertionError.invalidParameter("\(objectKey).\(key)") +} + +private func commandParameter( + _ command: WebInspectorTestPeer.Command, + _ key: String +) throws -> Any { + guard let object = try JSONSerialization.jsonObject( + with: command.parameters.data + ) as? [String: Any], let value = object[key] else { + throw RawCommandAssertionError.missingParameter(key) + } + return value +} + +@MainActor +func createFrameTarget( + in runtime: DataKitTestRuntime, + id: String = "frame-test", + frameID: String = "frame-test" +) async throws -> WebInspectorTarget { + try await runtime.peer.createTarget(WebInspectorTestPeer.Target( + id: id, + type: "frame", + frameID: frameID, + parentFrameID: "main-frame" + )) + return runtime.proxy.frameTarget(id: WebInspectorTarget.ID(id)) +} + +func wireTargetID(_ target: WebInspectorTarget) -> String { + if let pageBindingID = target.pageBindingID { + return pageBindingID + } + switch target.route.storage { + case let .target(rawValue): + return rawValue + case .currentPage: + preconditionFailure("A current-page test target has no physical page binding.") + } +} diff --git a/Tests/WebInspectorDataKitTests/DataKitRawEventFixtures.swift b/Tests/WebInspectorDataKitTests/DataKitRawEventFixtures.swift new file mode 100644 index 00000000..815e54d3 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DataKitRawEventFixtures.swift @@ -0,0 +1,938 @@ +import Foundation +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +private struct RawWireEvent { + let method: String + let parameters: WebInspectorTestJSONObject +} + +enum RawWireFixtureError: Error, Equatable { + case missingRequiredField(method: String, field: String) +} + +extension DataKitRawWireDriver { + func emitRaw(_ event: DOM.Event, target: WebInspectorTarget) async throws { + try await emit(rawDOMEvent(event), targetID: wireTargetID(target)) + } + + func emitRaw(_ event: DOM.Event, target: WebInspectorTarget.ID) async throws { + try await emit(rawDOMEvent(event), targetID: target.rawValue) + } + + func emitRaw(_ event: CSS.Event, target: WebInspectorTarget) async throws { + try await emit(rawCSSEvent(event), targetID: wireTargetID(target)) + } + + func emitRaw(_ event: CSS.Event, target: WebInspectorTarget.ID) async throws { + try await emit(rawCSSEvent(event), targetID: target.rawValue) + } + + func emitRaw(_ event: Network.Event, target: WebInspectorTarget) async throws { + try await emit(rawNetworkEvent(event), targetID: wireTargetID(target)) + } + + func emitRaw(_ event: Network.Event, target: WebInspectorTarget.ID) async throws { + try await emit(rawNetworkEvent(event), targetID: target.rawValue) + } + + func emitRaw(_ event: Console.Event, target: WebInspectorTarget) async throws { + try await emit(rawConsoleEvent(event), targetID: wireTargetID(target)) + } + + func emitRaw(_ event: Console.Event, target: WebInspectorTarget.ID) async throws { + try await emit(rawConsoleEvent(event), targetID: target.rawValue) + } + + func emitRaw(_ event: Runtime.Event, target: WebInspectorTarget) async throws { + try await emit(rawRuntimeEvent(event), targetID: wireTargetID(target)) + } + + func emitRaw(_ event: Runtime.Event, target: WebInspectorTarget.ID) async throws { + try await emit(rawRuntimeEvent(event), targetID: target.rawValue) + } + + func emitRaw( + _ event: WebInspectorTargetLifecycleEvent, + target: WebInspectorTarget + ) async throws { + try await emitLifecycle(event, targetID: wireTargetID(target)) + } + + func emitRaw( + _ event: WebInspectorTargetLifecycleEvent, + target: WebInspectorTarget.ID + ) async throws { + try await emitLifecycle(event, targetID: target.rawValue) + } + + private func emit(_ event: RawWireEvent, targetID: String) async throws { + try await emitTargetEvent( + targetID: targetID, + method: event.method, + parameters: event.parameters + ) + } + + private func emitLifecycle( + _ event: WebInspectorTargetLifecycleEvent, + targetID: String + ) async throws { + switch event { + case let .didCommitProvisionalTarget(commit): + try await emitRootEvent( + method: "Target.didCommitProvisionalTarget", + parameters: try testJSONObject(TargetCommitWire( + oldTargetId: commit.oldTargetID?.rawValue ?? targetID, + newTargetId: commit.newTarget.id.rawValue + )) + ) + case let .targetDestroyed(destroyedTargetID): + try await emitRootEvent( + method: "Target.targetDestroyed", + parameters: try testJSONObject(TargetDestroyedWire( + targetId: destroyedTargetID.rawValue + )) + ) + case let .frameNavigated(frame): + try await emitTargetEvent( + targetID: targetID, + method: "Page.frameNavigated", + parameters: try testJSONObject(PageFrameNavigatedWire( + frame: PageFrameWire(frame) + )) + ) + case let .frameDetached(frameID): + try await emitTargetEvent( + targetID: targetID, + method: "Page.frameDetached", + parameters: try testJSONObject(PageFrameDetachedWire( + frameId: frameID.rawValue + )) + ) + case let .unknown(event): + try await emit(try rawUnknownEvent(event), targetID: targetID) + } + } +} + +private func rawDOMEvent(_ event: DOM.Event) throws -> RawWireEvent { + switch event { + case .documentUpdated: + return try rawEvent("DOM.documentUpdated", EmptyWireObject()) + case let .setChildNodes(parent, nodes): + return try rawEvent( + "DOM.setChildNodes", + DOMSetChildNodesWire(parentId: parent.rawValue, nodes: nodes.map(DOMNodeWire.init)) + ) + case let .detachedRoot(node): + return try rawEvent( + "DOM.setChildNodes", + DOMSetChildNodesWire(parentId: "0", nodes: [DOMNodeWire(node)]) + ) + case let .childNodeInserted(parent, previous, node): + return try rawEvent( + "DOM.childNodeInserted", + DOMChildNodeInsertedWire( + parentNodeId: parent.rawValue, + previousNodeId: previous?.rawValue, + node: DOMNodeWire(node) + ) + ) + case let .childNodeRemoved(parent, node): + return try rawEvent( + "DOM.childNodeRemoved", + DOMChildNodeRemovedWire(parentNodeId: parent.rawValue, nodeId: node.rawValue) + ) + case let .childNodeCountUpdated(node, count): + return try rawEvent( + "DOM.childNodeCountUpdated", + DOMChildNodeCountUpdatedWire(nodeId: node.rawValue, childNodeCount: count) + ) + case let .attributeModified(node, name, value): + return try rawEvent( + "DOM.attributeModified", + DOMAttributeModifiedWire(nodeId: node.rawValue, name: name, value: value) + ) + case let .attributeRemoved(node, name): + return try rawEvent( + "DOM.attributeRemoved", + DOMAttributeRemovedWire(nodeId: node.rawValue, name: name) + ) + case let .inlineStyleInvalidated(nodes): + return try rawEvent( + "DOM.inlineStyleInvalidated", + DOMInlineStyleInvalidatedWire(nodeIds: nodes.map(\.rawValue)) + ) + case let .characterDataModified(node, value): + return try rawEvent( + "DOM.characterDataModified", + DOMCharacterDataModifiedWire(nodeId: node.rawValue, characterData: value) + ) + case let .shadowRootPushed(host, root): + return try rawEvent( + "DOM.shadowRootPushed", + DOMShadowRootPushedWire(hostId: host.rawValue, root: DOMNodeWire(root)) + ) + case let .shadowRootPopped(host, root): + return try rawEvent( + "DOM.shadowRootPopped", + DOMShadowRootPoppedWire(hostId: host.rawValue, rootId: root.rawValue) + ) + case let .pseudoElementAdded(parent, element): + return try rawEvent( + "DOM.pseudoElementAdded", + DOMPseudoElementAddedWire(parentId: parent.rawValue, pseudoElement: DOMNodeWire(element)) + ) + case let .pseudoElementRemoved(parent, element): + return try rawEvent( + "DOM.pseudoElementRemoved", + DOMPseudoElementRemovedWire(parentId: parent.rawValue, pseudoElementId: element.rawValue) + ) + case let .willDestroyDOMNode(node): + return try rawEvent("DOM.willDestroyDOMNode", DOMNodeIDWire(nodeId: node.rawValue)) + case let .inspect(node): + return try rawEvent("DOM.inspect", DOMNodeIDWire(nodeId: node.rawValue)) + case let .unknown(event): + return try rawUnknownEvent(event) + } +} + +private func rawCSSEvent(_ event: CSS.Event) throws -> RawWireEvent { + switch event { + case let .styleSheetChanged(id): + return try rawEvent("CSS.styleSheetChanged", CSSStyleSheetIDWire(styleSheetId: id.rawValue)) + case let .styleSheetAdded(header): + return try rawEvent( + "CSS.styleSheetAdded", + CSSStyleSheetAddedWire(header: CSSStyleSheetHeaderWire(header)) + ) + case let .styleSheetRemoved(id): + return try rawEvent("CSS.styleSheetRemoved", CSSStyleSheetIDWire(styleSheetId: id.rawValue)) + case .mediaQueryResultChanged: + return try rawEvent("CSS.mediaQueryResultChanged", EmptyWireObject()) + case let .nodeLayoutFlagsChanged(node): + return try rawEvent("CSS.nodeLayoutFlagsChanged", DOMNodeIDWire(nodeId: node.rawValue)) + case let .unknown(event): + return try rawUnknownEvent(event) + } +} + +private func rawNetworkEvent(_ event: Network.Event) throws -> RawWireEvent { + switch event { + case let .requestWillBeSent(id, request, resourceType, redirectResponse, timestamp): + return try rawEvent( + "Network.requestWillBeSent", + NetworkRequestWillBeSentWire( + requestId: id.rawValue, + request: NetworkRequestWire(request), + type: resourceType?.rawValue, + redirectResponse: redirectResponse.map(NetworkResponseWire.init), + timestamp: timestamp, + backendResourceIdentifier: request.backendResourceIdentifier.map(NetworkBackendResourceWire.init) + ) + ) + case let .responseReceived(id, response, resourceType, timestamp): + return try rawEvent( + "Network.responseReceived", + NetworkResponseReceivedWire( + requestId: id.rawValue, + type: resourceType?.rawValue, + response: NetworkResponseWire(response), + timestamp: timestamp + ) + ) + case let .dataReceived(id, dataLength, encodedDataLength, timestamp): + return try rawEvent( + "Network.dataReceived", + NetworkDataReceivedWire( + requestId: id.rawValue, + dataLength: dataLength, + encodedDataLength: encodedDataLength, + timestamp: timestamp + ) + ) + case let .loadingFinished(id, timestamp, sourceMapURL, metrics): + return try rawEvent( + "Network.loadingFinished", + NetworkLoadingFinishedWire( + requestId: id.rawValue, + timestamp: timestamp, + sourceMapURL: sourceMapURL, + metrics: metrics.map(NetworkMetricsWire.init) + ) + ) + case let .loadingFailed(id, errorText, cancelled, timestamp): + return try rawEvent( + "Network.loadingFailed", + NetworkLoadingFailedWire( + requestId: id.rawValue, + timestamp: timestamp, + errorText: errorText, + canceled: cancelled + ) + ) + case let .requestServedFromMemoryCache(id, response, resourceType, timestamp): + guard let url = response.url else { + throw RawWireFixtureError.missingRequiredField( + method: "Network.requestServedFromMemoryCache", + field: "resource.url" + ) + } + return try rawEvent( + "Network.requestServedFromMemoryCache", + NetworkMemoryCacheWire( + requestId: id.rawValue, + timestamp: timestamp, + resource: NetworkCachedResourceWire( + url: url, + type: resourceType?.rawValue ?? Network.ResourceType.other.rawValue, + bodySize: response.bodySize, + response: NetworkResponseWire(response) + ) + ) + ) + case let .webSocket(event): + return try rawWebSocketEvent(event) + case let .unknown(event): + return try rawUnknownEvent(event) + } +} + +private func rawWebSocketEvent(_ event: Network.WebSocketEvent) throws -> RawWireEvent { + switch event { + case let .created(id, url): + return try rawEvent( + "Network.webSocketCreated", + NetworkWebSocketCreatedWire(requestId: id.rawValue, url: url) + ) + case let .handshakeRequest(id, request, timestamp): + return try rawEvent( + "Network.webSocketWillSendHandshakeRequest", + NetworkWebSocketHandshakeRequestWire( + requestId: id.rawValue, + timestamp: timestamp, + request: NetworkWebSocketRequestWire(headers: request.headers) + ) + ) + case let .handshakeResponse(id, response, timestamp): + return try rawEvent( + "Network.webSocketHandshakeResponseReceived", + NetworkWebSocketHandshakeResponseWire( + requestId: id.rawValue, + timestamp: timestamp, + response: NetworkResponseWire(response) + ) + ) + case let .closed(id, timestamp): + return try rawEvent( + "Network.webSocketClosed", + NetworkWebSocketClosedWire(requestId: id.rawValue, timestamp: timestamp) + ) + case let .frameSent(id, frame, timestamp): + return try rawEvent( + "Network.webSocketFrameSent", + NetworkWebSocketFrameEventWire( + requestId: id.rawValue, + timestamp: timestamp, + response: NetworkWebSocketFrameWire(frame) + ) + ) + case let .frameReceived(id, frame, timestamp): + return try rawEvent( + "Network.webSocketFrameReceived", + NetworkWebSocketFrameEventWire( + requestId: id.rawValue, + timestamp: timestamp, + response: NetworkWebSocketFrameWire(frame) + ) + ) + case let .error(id, message, timestamp): + return try rawEvent( + "Network.webSocketFrameError", + NetworkWebSocketErrorWire( + requestId: id.rawValue, + timestamp: timestamp, + errorMessage: message + ) + ) + case let .other(event): + return try rawUnknownEvent(event) + } +} + +private func rawConsoleEvent(_ event: Console.Event) throws -> RawWireEvent { + switch event { + case let .messageAdded(message): + return try rawEvent( + "Console.messageAdded", + ConsoleMessageAddedWire(message: ConsoleMessageWire(message)) + ) + case let .messageRepeatCountUpdated(count, timestamp): + return try rawEvent( + "Console.messageRepeatCountUpdated", + ConsoleRepeatCountWire(count: count, timestamp: timestamp) + ) + case let .messagesCleared(reason): + return try rawEvent("Console.messagesCleared", ConsoleMessagesClearedWire(reason: reason.rawValue)) + case let .unknown(event): + return try rawUnknownEvent(event) + } +} + +private func rawRuntimeEvent(_ event: Runtime.Event) throws -> RawWireEvent { + switch event { + case let .executionContextCreated(context): + return try rawEvent( + "Runtime.executionContextCreated", + RuntimeContextCreatedWire(context: RuntimeExecutionContextWire(context)) + ) + case let .executionContextDestroyed(id): + return try rawEvent( + "Runtime.executionContextDestroyed", + RuntimeContextDestroyedWire(executionContextId: id.rawValue) + ) + case .executionContextsCleared: + return try rawEvent("Runtime.executionContextsCleared", EmptyWireObject()) + case let .unknown(event): + return try rawUnknownEvent(event) + } +} + +private func rawEvent(_ method: String, _ parameters: some Encodable) throws -> RawWireEvent { + RawWireEvent(method: method, parameters: try testJSONObject(parameters)) +} + +private func rawUnknownEvent(_ event: RawEvent) throws -> RawWireEvent { + let method = event.method.contains(".") ? event.method : "\(event.domain).\(event.method)" + let parameters: WebInspectorTestJSONObject + if event.params.isEmpty { + parameters = .empty + } else { + guard let json = String(data: event.params, encoding: .utf8) else { + throw WebInspectorTestPeerError.invalidJSONObject + } + parameters = try WebInspectorTestJSONObject(json: json) + } + return RawWireEvent(method: method, parameters: parameters) +} + +private struct EmptyWireObject: Encodable {} + +private struct TargetCommitWire: Encodable { + let oldTargetId: String + let newTargetId: String +} + +private struct TargetDestroyedWire: Encodable { + let targetId: String +} + +private struct PageFrameNavigatedWire: Encodable { + let frame: PageFrameWire +} + +private struct PageFrameDetachedWire: Encodable { + let frameId: String +} + +private struct PageFrameWire: Encodable { + let id: String + let parentId: String? + let loaderId: String? + let name: String? + let url: String + let securityOrigin: String? + let mimeType: String? + + init(_ frame: WebInspectorPageFrameLifecycle) { + id = frame.id.rawValue + parentId = frame.parentID?.rawValue + loaderId = frame.loaderID + name = frame.name + url = frame.url + securityOrigin = frame.securityOrigin + mimeType = frame.mimeType + } +} + +private struct DOMSetChildNodesWire: Encodable { + let parentId: String + let nodes: [DOMNodeWire] +} + +private struct DOMChildNodeInsertedWire: Encodable { + let parentNodeId: String + let previousNodeId: String? + let node: DOMNodeWire +} + +private struct DOMChildNodeRemovedWire: Encodable { + let parentNodeId: String + let nodeId: String +} + +private struct DOMChildNodeCountUpdatedWire: Encodable { + let nodeId: String + let childNodeCount: Int +} + +private struct DOMAttributeModifiedWire: Encodable { + let nodeId: String + let name: String + let value: String +} + +private struct DOMAttributeRemovedWire: Encodable { + let nodeId: String + let name: String +} + +private struct DOMInlineStyleInvalidatedWire: Encodable { + let nodeIds: [String] +} + +private struct DOMCharacterDataModifiedWire: Encodable { + let nodeId: String + let characterData: String +} + +private struct DOMShadowRootPushedWire: Encodable { + let hostId: String + let root: DOMNodeWire +} + +private struct DOMShadowRootPoppedWire: Encodable { + let hostId: String + let rootId: String +} + +private struct DOMPseudoElementAddedWire: Encodable { + let parentId: String + let pseudoElement: DOMNodeWire +} + +private struct DOMPseudoElementRemovedWire: Encodable { + let parentId: String + let pseudoElementId: String +} + +private struct DOMNodeIDWire: Encodable { + let nodeId: String +} + +private struct CSSStyleSheetIDWire: Encodable { + let styleSheetId: String +} + +private struct CSSStyleSheetAddedWire: Encodable { + let header: CSSStyleSheetHeaderWire +} + +private struct CSSStyleSheetHeaderWire: Encodable { + let styleSheetId: String + let frameId: String? + let sourceURL: String? + let origin: String + let title: String? + let disabled: Bool + let isInline: Bool + let startLine: Int + let startColumn: Int + + init(_ header: CSS.StyleSheetHeader) { + styleSheetId = header.styleSheetID.rawValue + frameId = header.frameID?.rawValue + sourceURL = header.sourceURL + origin = header.origin.rawValue + title = header.title + disabled = header.disabled + isInline = header.isInline + startLine = header.startLine + startColumn = header.startColumn + } +} + +private struct NetworkRequestWillBeSentWire: Encodable { + let requestId: String + let request: NetworkRequestWire + let type: String? + let redirectResponse: NetworkResponseWire? + let timestamp: Double + let backendResourceIdentifier: NetworkBackendResourceWire? +} + +private struct NetworkRequestWire: Encodable { + let url: String + let method: String + let headers: [String: String] + let postData: String? + let referrerPolicy: String? + let integrity: String? + + init(_ request: Network.Request) { + url = request.url + method = request.method + headers = request.headers + postData = request.postData + referrerPolicy = request.referrerPolicy?.rawValue + integrity = request.integrity + } +} + +private struct NetworkBackendResourceWire: Encodable { + let sourceProcessID: String + let resourceID: String + + init(_ identifier: Network.BackendResourceID) { + sourceProcessID = identifier.sourceProcessID + resourceID = identifier.resourceID + } +} + +private struct NetworkResponseWire: Encodable { + let url: String? + let status: Int? + let statusText: String? + let headers: [String: String] + let mimeType: String? + let source: String? + let requestHeaders: [String: String]? + + init(_ response: Network.Response) { + url = response.url + status = response.status + statusText = response.statusText + headers = response.headers + mimeType = response.mimeType + source = response.source?.rawValue + requestHeaders = response.requestHeaders + } +} + +private struct NetworkResponseReceivedWire: Encodable { + let requestId: String + let type: String? + let response: NetworkResponseWire + let timestamp: Double +} + +private struct NetworkDataReceivedWire: Encodable { + let requestId: String + let dataLength: Int + let encodedDataLength: Int + let timestamp: Double +} + +private struct NetworkLoadingFinishedWire: Encodable { + let requestId: String + let timestamp: Double + let sourceMapURL: String? + let metrics: NetworkMetricsWire? +} + +private struct NetworkMetricsWire: Encodable { + let networkProtocol: String? + let remoteAddress: String? + let responseBodyBytesReceived: Int? + let responseBodyDecodedSize: Int? + + enum CodingKeys: String, CodingKey { + case networkProtocol = "protocol" + case remoteAddress + case responseBodyBytesReceived + case responseBodyDecodedSize + } + + init(_ metrics: Network.Metrics) { + networkProtocol = metrics.networkProtocol + remoteAddress = metrics.remoteAddress + responseBodyBytesReceived = metrics.encodedDataLength + responseBodyDecodedSize = metrics.decodedBodyLength + } +} + +private struct NetworkLoadingFailedWire: Encodable { + let requestId: String + let timestamp: Double + let errorText: String + let canceled: Bool +} + +private struct NetworkMemoryCacheWire: Encodable { + let requestId: String + let timestamp: Double + let resource: NetworkCachedResourceWire +} + +private struct NetworkCachedResourceWire: Encodable { + let url: String + let type: String + let bodySize: Int? + let response: NetworkResponseWire +} + +private struct NetworkWebSocketCreatedWire: Encodable { + let requestId: String + let url: String +} + +private struct NetworkWebSocketHandshakeRequestWire: Encodable { + let requestId: String + let timestamp: Double? + let request: NetworkWebSocketRequestWire +} + +private struct NetworkWebSocketRequestWire: Encodable { + let headers: [String: String] +} + +private struct NetworkWebSocketHandshakeResponseWire: Encodable { + let requestId: String + let timestamp: Double? + let response: NetworkResponseWire +} + +private struct NetworkWebSocketClosedWire: Encodable { + let requestId: String + let timestamp: Double +} + +private struct NetworkWebSocketFrameEventWire: Encodable { + let requestId: String + let timestamp: Double + let response: NetworkWebSocketFrameWire +} + +private struct NetworkWebSocketFrameWire: Encodable { + let opcode: Int + let mask: Bool + let payloadData: String + let payloadLength: Int + + init(_ frame: Network.WebSocketFrame) { + opcode = frame.opcode + mask = frame.mask + payloadData = frame.payloadData + payloadLength = frame.payloadLength + } +} + +private struct NetworkWebSocketErrorWire: Encodable { + let requestId: String + let timestamp: Double + let errorMessage: String +} + +private struct ConsoleMessageAddedWire: Encodable { + let message: ConsoleMessageWire +} + +private struct ConsoleMessageWire: Encodable { + let source: String + let level: String + let type: String? + let text: String + let url: String? + let line: Int? + let column: Int? + let repeatCount: Int + let parameters: [RuntimeRemoteObjectWire] + let stackTrace: ConsoleStackTraceWire? + let networkRequestId: String? + let timestamp: Double? + + init(_ message: Console.Message) { + source = message.source.rawValue + level = message.level.rawValue + type = message.type?.rawValue + text = message.text + url = message.url + line = message.line + column = message.column + repeatCount = message.repeatCount + parameters = message.parameters.map(RuntimeRemoteObjectWire.init) + stackTrace = message.stackTrace.map(ConsoleStackTraceWire.init) + networkRequestId = message.networkRequestID?.rawValue + timestamp = message.timestamp + } +} + +private struct ConsoleStackTraceWire: Encodable { + let callFrames: [ConsoleCallFrameWire] + + init(_ stackTrace: Console.StackTrace) { + callFrames = stackTrace.callFrames.map(ConsoleCallFrameWire.init) + } +} + +private struct ConsoleCallFrameWire: Encodable { + let functionName: String + let url: String + let lineNumber: Int + let columnNumber: Int + + init(_ frame: Console.CallFrame) { + functionName = frame.functionName + url = frame.url + lineNumber = frame.line + columnNumber = frame.column + } +} + +private struct ConsoleRepeatCountWire: Encodable { + let count: Int + let timestamp: Double? +} + +private struct ConsoleMessagesClearedWire: Encodable { + let reason: String +} + +private struct RuntimeContextCreatedWire: Encodable { + let context: RuntimeExecutionContextWire +} + +private struct RuntimeContextDestroyedWire: Encodable { + let executionContextId: String +} + +private struct RuntimeExecutionContextWire: Encodable { + let id: String + let name: String + let frameId: String? + let type: String + + init(_ context: Runtime.ExecutionContext) { + id = context.id.rawValue + name = context.name + frameId = context.frameID?.rawValue + switch context.kind { + case .normal: type = "normal" + case .user: type = "user" + case .internalContext: type = "internal" + case let .other(value): type = value + } + } +} + +struct RuntimeRemoteObjectWire: Encodable { + let objectId: String? + let type: String + let subtype: String? + let className: String? + let description: String? + let value: RuntimeJSONValueWire? + let size: Int? + let preview: RuntimeObjectPreviewWire? + + init(_ object: Runtime.RemoteObject) { + objectId = object.id?.rawValue + let kind = Self.wireKind(object.kind, explicitSubtype: object.subtype?.rawValue) + type = kind.type + subtype = kind.subtype + className = object.className + description = object.description + value = object.value.map(RuntimeJSONValueWire.init) + size = object.size + preview = object.preview.map(RuntimeObjectPreviewWire.init) + } + + private static func wireKind( + _ kind: Runtime.Kind, + explicitSubtype: String? + ) -> (type: String, subtype: String?) { + switch kind { + case .object: ("object", explicitSubtype) + case .function: ("function", explicitSubtype) + case .string: ("string", explicitSubtype) + case .number: ("number", explicitSubtype) + case .boolean: ("boolean", explicitSubtype) + case .symbol: ("symbol", explicitSubtype) + case .bigint: ("bigint", explicitSubtype) + case .undefined: ("undefined", explicitSubtype) + case .null: ("object", explicitSubtype ?? "null") + case .array: ("object", explicitSubtype ?? "array") + case .error: ("object", explicitSubtype ?? "error") + case let .other(value): (value, explicitSubtype) + } + } +} + +struct RuntimeJSONValueWire: Encodable { + let value: Runtime.JSONValue + + init(_ value: Runtime.JSONValue) { + self.value = value + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let .string(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .bool(value): try container.encode(value) + case .null: try container.encodeNil() + case let .array(values): try container.encode(values.map(Self.init)) + case let .object(values): try container.encode(values.mapValues(Self.init)) + } + } +} + +struct RuntimeObjectPreviewWire: Encodable { + let type: String? + let subtype: String? + let description: String? + let lossless: Bool + let overflow: Bool + let properties: [RuntimePropertyPreviewWire] + let entries: [RuntimeEntryPreviewWire] + let size: Int? + + init(_ preview: Runtime.ObjectPreview) { + type = preview.kind.map { RuntimeRemoteObjectWire( + Runtime.RemoteObject(id: nil, kind: $0) + ).type } + subtype = preview.subtype?.rawValue + description = preview.description + lossless = preview.lossless + overflow = preview.overflow + properties = preview.properties.map(RuntimePropertyPreviewWire.init) + entries = preview.entries.map(RuntimeEntryPreviewWire.init) + size = preview.size + } +} + +struct RuntimePropertyPreviewWire: Encodable { + let name: String + let value: String? + + init(_ preview: Runtime.PropertyPreview) { + name = preview.name + value = preview.value + } +} + +struct RuntimeEntryPreviewWire: Encodable { + let key: RuntimeRemoteObjectWire? + let value: RuntimeRemoteObjectWire? + + init(_ preview: Runtime.EntryPreview) { + key = preview.key.map { + RuntimeRemoteObjectWire(Runtime.RemoteObject( + id: nil, + kind: .string, + description: $0, + value: .string($0) + )) + } + value = preview.value.map { + RuntimeRemoteObjectWire(Runtime.RemoteObject( + id: nil, + kind: .string, + description: $0, + value: .string($0) + )) + } + } +} diff --git a/Tests/WebInspectorDataKitTests/DataKitRawResponseFixtures.swift b/Tests/WebInspectorDataKitTests/DataKitRawResponseFixtures.swift new file mode 100644 index 00000000..6b70fade --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DataKitRawResponseFixtures.swift @@ -0,0 +1,336 @@ +import Foundation +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +func rawOuterHTMLResult(_ html: String) throws -> WebInspectorTestJSONObject { + try testJSONObject(DOMOuterHTMLResultWire(outerHTML: html)) +} + +func rawCSSMatchedStylesResult(_ styles: CSS.MatchedStyles) throws -> WebInspectorTestJSONObject { + try testJSONObject(CSSMatchedStylesResultWire(styles)) +} + +func rawCSSInlineStylesResult(_ styles: CSS.InlineStyles) throws -> WebInspectorTestJSONObject { + try testJSONObject(CSSInlineStylesResultWire(styles)) +} + +func rawCSSComputedStyleResult( + _ properties: [CSS.ComputedProperty] +) throws -> WebInspectorTestJSONObject { + try testJSONObject(CSSComputedStyleResultWire( + computedStyle: properties.map(CSSComputedPropertyWire.init) + )) +} + +func rawCSSStyleResult(_ style: CSS.Style) throws -> WebInspectorTestJSONObject { + try testJSONObject(CSSSetStyleResultWire(style: CSSStyleWire(style))) +} + +func rawCSSRuleResult(_ rule: CSS.Rule) throws -> WebInspectorTestJSONObject { + try testJSONObject(CSSSetRuleResultWire(rule: CSSRuleWire(rule))) +} + +func rawNetworkBodyResult(_ body: Network.Body) throws -> WebInspectorTestJSONObject { + try testJSONObject(NetworkBodyResultWire( + body: body.data, + base64Encoded: body.base64Encoded + )) +} + +func rawRuntimeEvaluationResult( + _ result: Runtime.EvaluationResult +) throws -> WebInspectorTestJSONObject { + try testJSONObject(RuntimeEvaluationResultWire( + result: RuntimeRemoteObjectWire(result.object), + wasThrown: result.wasThrown, + savedResultIndex: result.savedResultIndex + )) +} + +func rawRuntimePropertiesResult( + _ properties: [Runtime.PropertyDescriptor] +) throws -> WebInspectorTestJSONObject { + try testJSONObject(RuntimePropertiesResultWire( + properties: properties.map(RuntimePropertyDescriptorWire.init) + )) +} + +func rawRuntimeCollectionEntriesResult( + _ entries: [Runtime.CollectionEntry] +) throws -> WebInspectorTestJSONObject { + try testJSONObject(RuntimeCollectionEntriesResultWire( + entries: entries.map(RuntimeCollectionEntryWire.init) + )) +} + +private struct DOMOuterHTMLResultWire: Encodable { + let outerHTML: String +} + +private struct CSSMatchedStylesResultWire: Encodable { + let matchedCSSRules: [CSSRuleMatchWire] + let pseudoElements: [CSSPseudoElementMatchesWire] + let inherited: [CSSInheritedEntryWire] + + init(_ styles: CSS.MatchedStyles) { + matchedCSSRules = styles.matchedRules.map { CSSRuleMatchWire(rule: CSSRuleWire($0)) } + pseudoElements = styles.pseudoElements.map(CSSPseudoElementMatchesWire.init) + inherited = styles.inherited.map(CSSInheritedEntryWire.init) + } +} + +private struct CSSRuleMatchWire: Encodable { + let rule: CSSRuleWire +} + +private struct CSSPseudoElementMatchesWire: Encodable { + let pseudoId: String + let matches: [CSSRuleMatchWire] + + init(_ value: CSS.MatchedStyles.PseudoElementMatches) { + pseudoId = value.pseudoID + matches = value.matchedRules.map { CSSRuleMatchWire(rule: CSSRuleWire($0)) } + } +} + +private struct CSSInheritedEntryWire: Encodable { + let inlineStyle: CSSStyleWire? + let matchedCSSRules: [CSSRuleMatchWire] + + init(_ value: CSS.MatchedStyles.InheritedEntry) { + inlineStyle = value.inlineStyle.map(CSSStyleWire.init) + matchedCSSRules = value.matchedRules.map { CSSRuleMatchWire(rule: CSSRuleWire($0)) } + } +} + +private struct CSSInlineStylesResultWire: Encodable { + let inlineStyle: CSSStyleWire? + let attributesStyle: CSSStyleWire? + + init(_ styles: CSS.InlineStyles) { + inlineStyle = styles.inlineStyle.map(CSSStyleWire.init) + attributesStyle = styles.attributesStyle.map(CSSStyleWire.init) + } +} + +private struct CSSComputedStyleResultWire: Encodable { + let computedStyle: [CSSComputedPropertyWire] +} + +private struct CSSComputedPropertyWire: Encodable { + let name: String + let value: String + + init(_ property: CSS.ComputedProperty) { + name = property.name + value = property.value + } +} + +private struct CSSSetStyleResultWire: Encodable { + let style: CSSStyleWire +} + +private struct CSSSetRuleResultWire: Encodable { + let rule: CSSRuleWire +} + +private struct CSSRuleWire: Encodable { + let ruleId: CSSBackendIDWire? + let selectorList: CSSSelectorListWire + let sourceURL: String? + let sourceLine: Int? + let sourceLocation: CSSSourceRangeWire? + let origin: String + let style: CSSStyleWire + let groupings: [CSSGroupingWire] + let isImplicitlyNested: Bool + + init(_ rule: CSS.Rule) { + ruleId = rule.id.map { CSSBackendIDWire($0.unscopedRawValue) } + selectorList = CSSSelectorListWire(rule.selectorList) + sourceURL = rule.sourceURL + sourceLine = rule.sourceLine + sourceLocation = rule.sourceLocation.map(CSSSourceRangeWire.init) + origin = rule.origin.rawValue + style = CSSStyleWire(rule.style) + groupings = rule.groupings.map(CSSGroupingWire.init) + isImplicitlyNested = rule.isImplicitlyNested + } +} + +private struct CSSSelectorListWire: Encodable { + let selectors: [CSSSelectorWire] + let text: String + let range: CSSSourceRangeWire? + + init(_ list: CSS.Rule.SelectorList) { + selectors = list.selectors.map(CSSSelectorWire.init) + text = list.text + range = list.range.map(CSSSourceRangeWire.init) + } +} + +private struct CSSSelectorWire: Encodable { + let text: String + + init(_ text: String) { + self.text = text + } +} + +private struct CSSGroupingWire: Encodable { + let text: String + + init(_ grouping: CSS.Rule.Grouping) { + text = grouping.text + } +} + +private struct CSSStyleWire: Encodable { + let styleId: CSSBackendIDWire? + let cssProperties: [CSSPropertyWire] + let shorthandEntries: [CSSShorthandWire] + let cssText: String + let range: CSSSourceRangeWire? + let width: String? + let height: String? + + init(_ style: CSS.Style) { + styleId = style.isEditable ? CSSBackendIDWire(style.id.unscopedRawValue) : nil + cssProperties = style.properties.map(CSSPropertyWire.init) + shorthandEntries = style.shorthandEntries.map(CSSShorthandWire.init) + cssText = style.cssText + range = style.range.map(CSSSourceRangeWire.init) + width = style.width + height = style.height + } +} + +private struct CSSBackendIDWire: Encodable { + let styleSheetId: String + let ordinal: Int + + init(_ rawValue: String) { + let separator = "\u{1F}" + let components = rawValue.components(separatedBy: separator) + if components.count > 1, let ordinal = Int(components.last ?? "") { + styleSheetId = components.dropLast().joined(separator: separator) + self.ordinal = ordinal + } else { + styleSheetId = rawValue + ordinal = 0 + } + } +} + +private struct CSSPropertyWire: Encodable { + let name: String + let value: String + let priority: String? + let text: String? + let parsedOk: Bool + let status: String + let implicit: Bool + let range: CSSSourceRangeWire? + + init(_ property: CSS.Property) { + name = property.name + value = property.value + priority = property.priority + text = property.text + parsedOk = property.parsedOk + switch property.status { + case .active: status = "active" + case .inactive: status = "inactive" + case .disabled: status = "disabled" + } + implicit = property.implicit + range = property.range.map(CSSSourceRangeWire.init) + } +} + +private struct CSSShorthandWire: Encodable { + let name: String + let value: String + let priority: String? + + init(_ shorthand: CSS.Style.ShorthandEntry) { + name = shorthand.name + value = shorthand.value + priority = shorthand.priority + } +} + +private struct CSSSourceRangeWire: Encodable { + let startLine: Int + let startColumn: Int + let endLine: Int + let endColumn: Int + + init(_ range: CSS.Style.SourceRange) { + startLine = range.startLine + startColumn = range.startColumn + endLine = range.endLine + endColumn = range.endColumn + } +} + +private struct NetworkBodyResultWire: Encodable { + let body: String + let base64Encoded: Bool +} + +private struct RuntimeEvaluationResultWire: Encodable { + let result: RuntimeRemoteObjectWire + let wasThrown: Bool + let savedResultIndex: Int? +} + +private struct RuntimePropertiesResultWire: Encodable { + let properties: [RuntimePropertyDescriptorWire] +} + +private struct RuntimePropertyDescriptorWire: Encodable { + let name: String + let value: RuntimeRemoteObjectWire? + let writable: Bool? + let get: RuntimeRemoteObjectWire? + let set: RuntimeRemoteObjectWire? + let wasThrown: Bool? + let configurable: Bool? + let enumerable: Bool? + let isOwn: Bool? + let symbol: RuntimeRemoteObjectWire? + let isPrivate: Bool? + let nativeGetter: Bool? + + init(_ property: Runtime.PropertyDescriptor) { + name = property.name + value = property.value.map(RuntimeRemoteObjectWire.init) + writable = property.writable + get = property.get.map(RuntimeRemoteObjectWire.init) + set = property.set.map(RuntimeRemoteObjectWire.init) + wasThrown = property.wasThrown + configurable = property.configurable + enumerable = property.enumerable + isOwn = property.isOwn + symbol = property.symbol.map(RuntimeRemoteObjectWire.init) + isPrivate = property.isPrivate + nativeGetter = property.nativeGetter + } +} + +private struct RuntimeCollectionEntriesResultWire: Encodable { + let entries: [RuntimeCollectionEntryWire] +} + +private struct RuntimeCollectionEntryWire: Encodable { + let key: RuntimeRemoteObjectWire? + let value: RuntimeRemoteObjectWire + + init(_ entry: Runtime.CollectionEntry) { + key = entry.key.map(RuntimeRemoteObjectWire.init) + value = RuntimeRemoteObjectWire(entry.value) + } +} diff --git a/Tests/WebInspectorDataKitTests/DataKitRawWireSupport.swift b/Tests/WebInspectorDataKitTests/DataKitRawWireSupport.swift new file mode 100644 index 00000000..58cf9ed3 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/DataKitRawWireSupport.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +import WebInspectorProxyKit +import WebInspectorProxyKitTesting +import WebInspectorTestSupport + +typealias DataKitRawWireGate = WebInspectorTestGate +typealias DataKitRawWireDriver = WebInspectorRawWireDriver +typealias DOMNodeWire = WebInspectorDOMNodeWire + +struct DataKitTestRuntime: Sendable { + let runtime: WebInspectorProxyTestRuntime + let wire: DataKitRawWireDriver + + var proxy: WebInspectorProxy { runtime.proxy } + var peer: WebInspectorTestPeer { runtime.peer } + var page: WebInspectorPage { runtime.page } + + fileprivate static func start( + configuration: WebInspectorProxy.Configuration = .init(), + initialTarget: WebInspectorTestPeer.Target = .initialPage + ) async throws -> Self { + let runtime = try await WebInspectorProxyTestRuntime.start( + configuration: configuration, + initialTarget: initialTarget + ) + let wire = DataKitRawWireDriver(peer: runtime.peer) + await wire.start() + return Self(runtime: runtime, wire: wire) + } + + fileprivate func close() async { + // Core owns connection termination; the wire driver only joins its + // consumer and reply tasks after the peer mailbox becomes terminal. + await runtime.close() + await wire.stop() + } +} + +@MainActor +func withDataKitTestRuntime( + configuration: WebInspectorProxy.Configuration = .init(), + initialTarget: WebInspectorTestPeer.Target = .initialPage, + _ operation: @MainActor (DataKitTestRuntime) async throws -> Result +) async throws -> Result { + let testRuntime = try await DataKitTestRuntime.start( + configuration: configuration, + initialTarget: initialTarget + ) + + do { + let result = try await operation(testRuntime) + await testRuntime.close() + return result + } catch { + await testRuntime.close() + throw error + } +} + +func testJSONObject(_ json: String) throws -> WebInspectorTestJSONObject { + try webInspectorTestJSONObject(json) +} + +func testJSONObject(_ value: Value) throws -> WebInspectorTestJSONObject { + try webInspectorTestJSONObject(value) +} + +func emptyDocumentResult( + nodeID: String = "document", + frameID: String = "main-frame", + childNodeCount: Int = 0 +) throws -> WebInspectorTestJSONObject { + try testJSONObject( + """ + { + "root": { + "nodeId": "\(nodeID)", + "nodeType": 9, + "nodeName": "#document", + "localName": "", + "nodeValue": "", + "frameId": "\(frameID)", + "childNodeCount": \(childNodeCount) + } + } + """ + ) +} + +func domDocumentResult(_ document: DOM.Node) throws -> WebInspectorTestJSONObject { + try webInspectorDOMDocumentResult(document) +} diff --git a/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift new file mode 100644 index 00000000..bcfbb40a --- /dev/null +++ b/Tests/WebInspectorDataKitTests/NetworkConcreteQueryTests.swift @@ -0,0 +1,651 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func networkQueryNormalizesSearchAndProvidesClosedDefaults() { + var query = NetworkQuery(search: " app.js\n") + + #expect(query.search == "app.js") + #expect(query.resourceCategories.isEmpty) + #expect(query.methods.isEmpty) + #expect(query.sort == .requestTimeDescending) + #expect(query.section == nil) + #expect(query.offset == 0) + #expect(query.limit == nil) + + query.search = " \n\t " + #expect(query.search == nil) +} + +@MainActor +@Test +func networkConcreteQueryUsesInsertionOrderToBreakEqualRequestTimes() async throws { + let context = WebInspectorModelContext.preview() + var first = makeIndexedNetworkRecord( + id: "tie-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + var second = makeIndexedNetworkRecord( + id: "tie-second", + url: "https://example.com/second", + method: "GET", + timestamp: 1, + context: context + ) + first.input.orderIndex = 0 + second.input.orderIndex = 1 + let index = NetworkRequestIndex() + _ = await index.replace(with: [first.input, second.input], sequence: 1) + let lifetime = WebInspectorQueryRegistrationLifetime() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 0) + let ascendingGeneration = lifetime.nextGeneration() + let ascending = try await index.register( + id: registrationID, + generation: ascendingGeneration, + query: NetworkQuery(sort: .requestTimeAscending), + lifetime: lifetime, + minimumSequence: 1 + ) + #expect(ascending.snapshot.itemIDs == [first.id, second.id]) + + let descendingGeneration = lifetime.nextGeneration() + _ = try await index.prepareReplacement( + id: registrationID, + generation: descendingGeneration, + query: NetworkQuery(sort: .requestTimeDescending), + minimumSequence: 1 + ) + let descending = try #require(await index.commitReplacement( + id: registrationID, + generation: descendingGeneration + )) + #expect(descending.snapshot.itemIDs == [second.id, first.id]) +} + +@MainActor +@Test +func networkConcreteQueryRegistrationIncludesMutationAppliedWhileWaitingForInitialState() async throws { + let context = WebInspectorModelContext.preview() + let first = makeIndexedNetworkRecord( + id: "initial-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + let second = makeIndexedNetworkRecord( + id: "initial-second", + url: "https://example.com/second", + method: "POST", + timestamp: 2, + context: context + ) + let index = NetworkRequestIndex() + let secondMutation = Task { + await index.upsert(second.input, sequence: 2) + } + try await waitForConcreteQueryCondition { + await index.isMutationPendingForTesting(sequence: 2) + } + + let lifetime = WebInspectorQueryRegistrationLifetime() + let generation = lifetime.nextGeneration() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 1) + let registration = Task { + try await index.register( + id: registrationID, + generation: generation, + query: NetworkQuery(sort: .requestTimeAscending), + lifetime: lifetime, + minimumSequence: 2 + ) + } + try await waitForConcreteQueryCondition { + await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) + } + + _ = await index.upsert(first.input, sequence: 1) + _ = await secondMutation.value + let projection = try await registration.value + + #expect(projection.sequence == 2) + #expect(projection.snapshot.itemIDs == [first.id, second.id]) + #expect(await index.queryRegistrationCountForTesting() == 1) +} + +@Test +func cancelledNetworkConcreteQueryRegistrationStopsWaitingWithoutTheMissingMutation() async throws { + var index: NetworkRequestIndex? = NetworkRequestIndex() + weak let weakIndex = index + let lifetime = WebInspectorQueryRegistrationLifetime() + let generation = lifetime.nextGeneration() + let registration = Task { [index] in + guard let index else { + throw CancellationError() + } + return try await index.register( + id: WebInspectorQueryRegistrationID(rawValue: 11), + generation: generation, + query: NetworkQuery(), + lifetime: lifetime, + minimumSequence: 1 + ) + } + try await waitForConcreteQueryCondition { [weak index] in + guard let index else { + return false + } + return await index.isSequenceWaiterPendingForTesting(minimumSequence: 1) + } + + registration.cancel() + + await #expect(throws: CancellationError.self) { + try await registration.value + } + if let index { + #expect(await index.isSequenceWaiterPendingForTesting(minimumSequence: 1) == false) + #expect(await index.queryRegistrationCountForTesting() == 0) + } + index = nil + #expect(weakIndex == nil) +} + +@MainActor +@Test +func networkConcreteQueryReplacementAbsorbsMutationBetweenPrepareAndCommit() async throws { + let context = WebInspectorModelContext.preview() + let first = makeIndexedNetworkRecord( + id: "replacement-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + let second = makeIndexedNetworkRecord( + id: "replacement-second", + url: "https://example.com/second", + method: "POST", + timestamp: 2, + context: context + ) + let index = NetworkRequestIndex() + _ = await index.replace(with: [first.input], sequence: 1) + let lifetime = WebInspectorQueryRegistrationLifetime() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 2) + let initialGeneration = lifetime.nextGeneration() + _ = try await index.register( + id: registrationID, + generation: initialGeneration, + query: NetworkQuery(sort: .requestTimeAscending), + lifetime: lifetime, + minimumSequence: 1 + ) + + let replacementGeneration = lifetime.nextGeneration() + let replacementQuery = NetworkQuery( + methods: ["POST"], + sort: .requestTimeAscending + ) + let prepared = try await index.prepareReplacement( + id: registrationID, + generation: replacementGeneration, + query: replacementQuery, + minimumSequence: 1 + ) + #expect(prepared.snapshot.itemIDs.isEmpty) + + let deliveries = await index.upsert(second.input, sequence: 2) + let candidateDelivery = try #require(deliveries.first { + $0.generation == replacementGeneration + }) + #expect(candidateDelivery.projection.snapshot.itemIDs == [second.id]) + #expect(candidateDelivery.projection.reconfigureItemIDs == [second.id]) + + let committed = try #require(await index.commitReplacement( + id: registrationID, + generation: replacementGeneration + )) + #expect(committed.sequence == 2) + #expect(committed.snapshot.itemIDs == [second.id]) +} + +@MainActor +@Test +func cancelledNetworkConcreteQueryReplacementLeavesTheActiveGenerationWhole() async throws { + let context = WebInspectorModelContext.preview() + let first = makeIndexedNetworkRecord( + id: "cancel-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + let second = makeIndexedNetworkRecord( + id: "cancel-second", + url: "https://example.com/second", + method: "POST", + timestamp: 2, + context: context + ) + let index = NetworkRequestIndex() + _ = await index.replace(with: [first.input], sequence: 1) + let lifetime = WebInspectorQueryRegistrationLifetime() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 3) + let activeGeneration = lifetime.nextGeneration() + _ = try await index.register( + id: registrationID, + generation: activeGeneration, + query: NetworkQuery(sort: .requestTimeAscending), + lifetime: lifetime, + minimumSequence: 1 + ) + + let cancelledGeneration = lifetime.nextGeneration() + let replacement = Task { + try await index.prepareReplacement( + id: registrationID, + generation: cancelledGeneration, + query: NetworkQuery(methods: ["POST"]), + minimumSequence: 2 + ) + } + try await waitForConcreteQueryCondition { + await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) + } + replacement.cancel() + + await #expect(throws: CancellationError.self) { + try await replacement.value + } + #expect(await index.isSequenceWaiterPendingForTesting(minimumSequence: 2) == false) + #expect(await index.commitReplacement( + id: registrationID, + generation: cancelledGeneration + ) == nil) + + let deliveries = await index.upsert(second.input, sequence: 2) + #expect(deliveries.map(\.generation) == [activeGeneration]) + #expect(deliveries.first?.projection.snapshot.itemIDs == [first.id, second.id]) +} + +@MainActor +@Test +func overlappingNetworkConcreteQueryReplacementsCommitOnlyTheNewestGeneration() async throws { + let context = WebInspectorModelContext.preview() + let first = makeIndexedNetworkRecord( + id: "overlap-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + let second = makeIndexedNetworkRecord( + id: "overlap-second", + url: "https://example.com/second", + method: "POST", + timestamp: 2, + context: context + ) + let index = NetworkRequestIndex() + _ = await index.replace(with: [first.input], sequence: 1) + let lifetime = WebInspectorQueryRegistrationLifetime() + let registrationID = WebInspectorQueryRegistrationID(rawValue: 4) + let initialGeneration = lifetime.nextGeneration() + _ = try await index.register( + id: registrationID, + generation: initialGeneration, + query: NetworkQuery(), + lifetime: lifetime, + minimumSequence: 1 + ) + + let supersededGeneration = lifetime.nextGeneration() + _ = try await index.prepareReplacement( + id: registrationID, + generation: supersededGeneration, + query: NetworkQuery(methods: ["GET"]), + minimumSequence: 1 + ) + let newestGeneration = lifetime.nextGeneration() + _ = try await index.prepareReplacement( + id: registrationID, + generation: newestGeneration, + query: NetworkQuery(methods: ["POST"]), + minimumSequence: 1 + ) + + #expect(await index.commitReplacement( + id: registrationID, + generation: supersededGeneration + ) == nil) + let newest = try #require(await index.commitReplacement( + id: registrationID, + generation: newestGeneration + )) + #expect(newest.snapshot.itemIDs.isEmpty) + + let deliveries = await index.upsert(second.input, sequence: 2) + #expect(deliveries.map(\.generation) == [newestGeneration]) + #expect(deliveries.first?.projection.snapshot.itemIDs == [second.id]) +} + +@MainActor +@Test +func concreteFetchedResultsNeverRegressTheirSourceEpoch() throws { + let context = WebInspectorModelContext.preview() + let first = makeIndexedNetworkRecord( + id: "epoch-first", + url: "https://example.com/first", + method: "GET", + timestamp: 1, + context: context + ) + let second = makeIndexedNetworkRecord( + id: "epoch-second", + url: "https://example.com/second", + method: "GET", + timestamp: 2, + context: context + ) + guard let firstModel = try context.networkRequest(id: first.id), + let secondModel = try context.networkRequest(id: second.id) else { + Issue.record("Expected source-epoch fixtures to remain registered.") + return + } + let models = [first.id: firstModel, second.id: secondModel] + let results = WebInspectorFetchedResults(modelContext: context) + results.installInitialNetworkQuery( + NetworkQuery(), + generation: 1, + projection: NetworkRequestIndex.QueryProjection( + sourceEpoch: 1, + sequence: 1, + snapshot: WebInspectorFetchedResultsSnapshot(itemIDs: [first.id]), + reconfigureItemIDs: [] + ), + lookup: { models[$0] } + ) + + let resetApplied = results.applyNetworkQueryProjection( + NetworkRequestIndex.QueryProjection( + sourceEpoch: 2, + sequence: 2, + snapshot: WebInspectorFetchedResultsSnapshot(), + reconfigureItemIDs: [] + ), + query: NetworkQuery(), + generation: 1, + isReplacement: false, + lookup: { models[$0] } + ) + let staleNewGenerationApplied = results.applyNetworkQueryProjection( + NetworkRequestIndex.QueryProjection( + sourceEpoch: 1, + sequence: 3, + snapshot: WebInspectorFetchedResultsSnapshot(itemIDs: [second.id]), + reconfigureItemIDs: [second.id] + ), + query: NetworkQuery(methods: ["GET"]), + generation: 2, + isReplacement: true, + lookup: { models[$0] } + ) + + #expect(resetApplied) + #expect(staleNewGenerationApplied == false) + #expect(results.items.isEmpty) + #expect(results.snapshot.itemIDs.isEmpty) +} + +@MainActor +@Test +func networkConcreteQueryPublishesSameIdentityMoveSectionsWindowAndClear() async throws { + let context = WebInspectorModelContext.preview() + let store = NetworkRequestStore() + let firstProxyID = Network.Request.ID("store-first") + let secondProxyID = Network.Request.ID("store-second") + await addNetworkRequest( + firstProxyID, + url: "https://example.com/first.js", + method: "GET", + timestamp: 1, + store: store, + context: context + ) + await finishNetworkRequest(firstProxyID, timestamp: 1.5, store: store, context: context) + await addNetworkRequest( + secondProxyID, + url: "https://example.com/second.js", + method: "POST", + timestamp: 2, + store: store, + context: context + ) + await finishNetworkRequest(secondProxyID, timestamp: 2.5, store: store, context: context) + + let results = try await store.results( + matching: NetworkQuery( + sort: .requestTimeAscending, + section: .method, + limit: 2 + ), + modelContext: context, + ) + let firstID = NetworkRequest.ID(firstProxyID) + let secondID = NetworkRequest.ID(secondProxyID) + let registeredFirst = store.request( + forProxyID: firstProxyID, + ) + let firstIdentity = try #require(registeredFirst) + #expect(results.items.map(\.id) == [firstID, secondID]) + #expect(results.sections.map(\.id.rawValue) == ["GET", "POST"]) + var updates = results.updates().makeAsyncIterator() + guard case .initial? = await updates.next() else { + Issue.record("Expected an initial concrete Network query state.") + return + } + + await addNetworkRequest( + firstProxyID, + url: "https://example.com/first.js", + method: "PUT", + timestamp: 3, + store: store, + context: context + ) + #expect(results.items.map(\.id) == [secondID, firstID]) + #expect(results.sections.map(\.id.rawValue) == ["POST", "PUT"]) + #expect(results.items.last === firstIdentity) + guard case let .transaction(_, move, reconfigure)? = await updates.next() else { + Issue.record("Expected the same-ID Network query move transaction.") + return + } + #expect(move.newSnapshot == results.snapshot) + #expect(reconfigure == [firstID]) + + try await store.update( + NetworkQuery( + sort: .requestTimeAscending, + section: .method, + offset: 1, + limit: 1 + ), + for: results, + ) + #expect(results.items.map(\.id) == [firstID]) + #expect(results.sections.map(\.id.rawValue) == ["PUT"]) + + await store.clear() + #expect(results.items.isEmpty) + #expect(results.sections.isEmpty) + #expect(results.snapshot.itemIDs.isEmpty) +} + +@MainActor +@Test +func networkConcreteQueryProjectsTenThousandRecordsOffTheOwnerActor() async throws { + let context = WebInspectorModelContext.preview() + let store = NetworkRequestStore() + let recordCount = 10_000 + for ordinal in 0..? = try await store.results( + matching: NetworkQuery(), + modelContext: context, + ) + weak let weakResults = results + + let registrationCount = await store.concreteQueryRegistrationCountForTesting( + ) + #expect(registrationCount == 1) + results = nil + #expect(weakResults == nil) + let prunedRegistrationCount = await store.concreteQueryRegistrationCountForTesting( + ) + #expect(prunedRegistrationCount == 0) +} + +@MainActor +private func makeIndexedNetworkRecord( + id: String, + url: String, + method: String, + timestamp: Double, + context: WebInspectorModelContext +) -> (id: NetworkRequest.ID, input: NetworkRequestRecordInput) { + let modelID = context.seedNetworkRequest( + requestID: id, + url: url, + method: method, + resourceTypeRawValue: "Fetch", + responseMIMEType: "application/json", + responseStatus: 200, + responseStatusText: "OK", + timestamp: timestamp, + ) + guard let request = try! context.networkRequest(id: modelID) else { + preconditionFailure("The indexed Network fixture was not registered.") + } + return (modelID, NetworkRequestRecordInput(request: request, orderIndex: Int(timestamp))) +} + +@MainActor +private func addNetworkRequest( + _ id: Network.Request.ID, + url: String, + method: String, + timestamp: Double, + store: NetworkRequestStore, + context: WebInspectorModelContext +) async { + await store.apply( + .requestWillBeSent( + id: id, + request: Network.Request(id: id, url: url, method: method), + resourceType: .fetch, + redirectResponse: nil, + timestamp: timestamp + ), + modelContext: context, + ) +} + +@MainActor +private func finishNetworkRequest( + _ id: Network.Request.ID, + timestamp: Double, + store: NetworkRequestStore, + context: WebInspectorModelContext +) async { + await store.apply( + .loadingFinished( + id: id, + timestamp: timestamp, + sourceMapURL: nil, + metrics: nil + ), + modelContext: context, + ) +} + +func waitForConcreteQueryCondition( + timeout: Duration = .seconds(2), + _ condition: @escaping @Sendable () async -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while await condition() == false { + if clock.now >= deadline { + throw ConcreteQueryTimedOut() + } + await Task.yield() + } +} + +private struct ConcreteQueryTimedOut: Error {} diff --git a/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift new file mode 100644 index 00000000..57874990 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/NetworkRequestStoreTests.swift @@ -0,0 +1,66 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +@MainActor +@Test +func networkRequestStoreOwnsIdentityOrderAndClearEpoch() async throws { + let context = WebInspectorModelContext.preview() + let store = NetworkRequestStore() + let firstProxyID = Network.Request.ID("first") + let secondProxyID = Network.Request.ID("second") + + await store.apply( + .requestWillBeSent( + id: firstProxyID, + request: Network.Request( + id: firstProxyID, + url: "https://example.com/first", + method: "GET" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + modelContext: context + ) + let first = try #require(store.request(forProxyID: firstProxyID)) + + await store.apply( + .responseReceived( + id: firstProxyID, + response: Network.Response( + url: "https://example.com/first", + status: 200 + ), + resourceType: .fetch, + timestamp: 2 + ), + modelContext: context + ) + await store.apply( + .requestWillBeSent( + id: secondProxyID, + request: Network.Request( + id: secondProxyID, + url: "https://example.com/second", + method: "POST" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 3 + ), + modelContext: context + ) + + #expect(store.request(forProxyID: firstProxyID) === first) + #expect(first.statusCode == 200) + #expect(store.collectionState.requestCount == 2) + + await store.clear() + + #expect(store.request(forProxyID: firstProxyID) == nil) + #expect(store.request(forProxyID: secondProxyID) == nil) + #expect(store.collectionState.requestCount == 0) + #expect(store.collectionState.topologyRevision == 3) +} diff --git a/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift new file mode 100644 index 00000000..bd557912 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/RuntimeStateStoreTests.swift @@ -0,0 +1,314 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorProxyKit + +private actor RuntimeStateStoreIsolationProbe { + private let store = RuntimeStateStore() + + func exercise() -> (contextCount: Int, selectedName: String?) { + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: Runtime.ExecutionContext.ID("custom-actor-context"), + name: "Custom actor", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + return (store.executionContexts.count, store.selectedContext?.name) + } +} + +@Test +func runtimeStateStoreFollowsItsCallerActorWithoutRetainingIt() async { + var probe: RuntimeStateStoreIsolationProbe? = RuntimeStateStoreIsolationProbe() + weak let releasedProbe = probe + + let values = await probe?.exercise() + #expect(values?.contextCount == 1) + #expect(values?.selectedName == "Custom actor") + + probe = nil + for _ in 0..<100 where releasedProbe != nil { + await Task.yield() + } + #expect(releasedProbe == nil) +} + +@MainActor +@Test +func runtimeStateStoreOwnsContextIdentityOrderAndSelection() throws { + let store = RuntimeStateStore() + let firstID = Runtime.ExecutionContext.ID("first") + let secondID = Runtime.ExecutionContext.ID("second") + + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: firstID, + name: "First", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + let first = try #require(store.executionContexts.first) + #expect(store.selectedContext === first) + + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: firstID, + name: "First updated", + kind: .user + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + #expect(store.executionContexts == [first]) + #expect(first.name == "First updated") + #expect(first.kind == .user) + + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: secondID, + name: "Second", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + let second = try #require(store.executionContexts.last) + store.select(second) + #expect(store.selectedContext === second) + + store.apply( + .executionContextDestroyed(secondID), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + #expect(store.executionContexts == [first]) + #expect(store.selectedContext === first) +} + +@MainActor +@Test +func runtimeStateStoreKeepsGroupOwnershipAfterConsoleOwnershipEnds() throws { + let store = RuntimeStateStore() + let groupID = store.createGroupID() + let remoteID = Runtime.RemoteObject.ID("shared") + let consoleObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "console") + ) + let binding = try store.evaluationBinding(for: nil) + let evaluation = try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: remoteID, kind: .object, description: "group") + ), + binding: binding, + groupID: groupID + ) + + #expect(evaluation.object === consoleObject) + #expect(consoleObject.description == "group") + store.removeConsoleOwnership(from: [consoleObject]) + #expect(try store.objectBinding(for: consoleObject, groupID: groupID)?.remoteID == remoteID) + + store.invalidateGroup(groupID) + #expect(throws: WebInspectorModelError.staleModel) { + try store.objectBinding(for: consoleObject, groupID: groupID) + } +} + +@MainActor +@Test +func runtimeStateStoreOldPointerCannotRemoveSameRemoteIDReplacement() throws { + let store = RuntimeStateStore() + let remoteID = Runtime.RemoteObject.ID("reused-remote-id") + let oldObject = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "old") + ) + + store.reset() + let groupID = store.createGroupID() + let replacement = store.registerConsoleParameter( + Runtime.RemoteObject(id: remoteID, kind: .object, description: "replacement") + ) + let binding = try store.evaluationBinding(for: nil) + _ = try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: remoteID, kind: .object, description: "group-owned") + ), + binding: binding, + groupID: groupID + ) + + #expect(replacement !== oldObject) + store.removeConsoleOwnership(from: [oldObject]) + #expect(try store.objectBinding(for: replacement, groupID: groupID)?.remoteID == remoteID) +} + +@MainActor +@Test +func runtimeStateStoreNeverReusesSyntheticOrGroupIdentityAfterReset() { + let store = RuntimeStateStore() + let firstGroup = store.createGroupID() + let first = store.registerConsoleParameter( + Runtime.RemoteObject(id: nil, kind: .number, value: .number(1)) + ) + + store.reset() + + let secondGroup = store.createGroupID() + let second = store.registerConsoleParameter( + Runtime.RemoteObject(id: nil, kind: .number, value: .number(2)) + ) + #expect(first.id != second.id) + #expect(firstGroup != secondGroup) +} + +@MainActor +@Test +func runtimeStateStoreRejectsDefaultEvaluationReplyAfterFullReset() throws { + let store = RuntimeStateStore() + let oldBinding = try store.evaluationBinding(for: nil) + _ = store.createGroupID() + + store.reset() + let replacementGroup = store.createGroupID() + + #expect(throws: WebInspectorProxyError.disconnected( + "Runtime evaluation target is no longer current in this WebInspectorModelContext." + )) { + try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: nil, kind: .undefined) + ), + binding: oldBinding, + groupID: replacementGroup + ) + } +} + +@MainActor +@Test +func runtimeStateStoreTargetClearPreservesOtherTargetState() throws { + let store = RuntimeStateStore() + let pageTargetID = WebInspectorTarget.ID("page") + let frameTargetID = WebInspectorTarget.ID("frame") + let pageContextID = Runtime.ExecutionContext.ID( + "context", + scopedToTargetRawValue: pageTargetID.rawValue + ) + let frameContextID = Runtime.ExecutionContext.ID( + "context", + scopedToTargetRawValue: frameTargetID.rawValue + ) + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: pageContextID, + name: "Page", + kind: .normal + )), + sourceTargetID: pageTargetID, + isCurrentPageTarget: true + ) + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: frameContextID, + name: "Frame", + kind: .normal + )), + sourceTargetID: frameTargetID, + isCurrentPageTarget: false + ) + let pageContext = try #require(store.executionContexts.first) + let frameContext = try #require(store.executionContexts.last) + store.select(frameContext) + + let groupID = store.createGroupID() + let binding = try store.evaluationBinding(for: nil) + let pageObject = try store.finishEvaluation( + Runtime.EvaluationResult(object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID( + "page-object", + scopedToTargetRawValue: pageTargetID.rawValue + ), + kind: .object + )), + binding: binding, + groupID: groupID + ).object + let frameObject = try store.finishEvaluation( + Runtime.EvaluationResult(object: Runtime.RemoteObject( + id: Runtime.RemoteObject.ID( + "frame-object", + scopedToTargetRawValue: frameTargetID.rawValue + ), + kind: .object + )), + binding: binding, + groupID: groupID + ).object + + store.apply( + .executionContextsCleared, + sourceTargetID: frameTargetID, + isCurrentPageTarget: false + ) + + #expect(store.executionContexts.count == 1) + #expect(store.executionContexts.first === pageContext) + #expect(store.selectedContext === pageContext) + #expect(try store.objectBinding(for: pageObject, groupID: groupID) != nil) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeObject is not registered in this WebInspectorModelContext." + )) { + try store.objectBinding(for: frameObject, groupID: groupID) + } +} + +@MainActor +@Test +func runtimeStateStoreRejectsEvaluationReplyAfterContextIdentityReplacement() throws { + let store = RuntimeStateStore() + let contextID = Runtime.ExecutionContext.ID("reused-context") + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "Before", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + let original = try #require(store.executionContexts.first) + let binding = try store.evaluationBinding(for: original) + + store.apply( + .executionContextDestroyed(contextID), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + store.apply( + .executionContextCreated(Runtime.ExecutionContext( + id: contextID, + name: "After", + kind: .normal + )), + sourceTargetID: WebInspectorTarget.ID("page"), + isCurrentPageTarget: true + ) + let groupID = store.createGroupID() + + #expect(store.executionContexts.first !== original) + #expect(throws: WebInspectorProxyError.disconnected( + "RuntimeContext is not registered in this WebInspectorModelContext." + )) { + try store.finishEvaluation( + Runtime.EvaluationResult( + object: Runtime.RemoteObject(id: nil, kind: .undefined) + ), + binding: binding, + groupID: groupID + ) + } +} diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTestingProductTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTestingProductTests.swift new file mode 100644 index 00000000..19e37297 --- /dev/null +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTestingProductTests.swift @@ -0,0 +1,107 @@ +import Testing +@testable import WebInspectorDataKit +import WebInspectorDataKitTesting +import WebInspectorProxyKit + +@MainActor +@Test +func dataKitTestingStartsReadyWithReplayAndDrivesPickerSelection() async throws { + let replay = WebInspectorDataKitTestRuntime.NetworkRequest( + id: "initial-request", + url: "https://example.test/initial", + status: 201, + body: Network.Body(data: "ready body") + ) + let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.dom, .network]), + document: .init(children: [ + .element(id: "button", name: "button") + ]), + networkReplay: [replay] + ), + isolation: MainActor.shared + ) + + #expect(runtime.model.state == .attached) + #expect(try runtime.model.rootDOMNode?.nodeName == "#document") + let request = try #require(try runtime.model.networkRequest( + id: NetworkRequest.ID(Network.Request.ID("initial-request")) + )) + #expect(request.statusCode == 201) + #expect(request.state == .finished) + + let selected = try await runtime.selectElementWithPicker(nodeID: "button") + #expect(selected.localName == "button") + #expect(try runtime.model.selectedDOMNode === selected) + + let body = try await runtime.model.responseBody(for: request) + #expect(body.text == "ready body") + + await runtime.close() + #expect(runtime.model.state == .closed) +} + +@MainActor +@Test +func dataKitTestingWaitsForReplacementBootstrapAndReplaysNewPage() async throws { + let runtime = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.dom, .network]), + document: .init(children: [ + .element(id: "old-node", name: "main") + ]) + ), + isolation: MainActor.shared + ) + let precedingGeneration = runtime.model.pageGeneration + + try await runtime.replacePage( + with: .init(children: [ + .element(id: "new-node", name: "article") + ]), + networkReplay: [ + .init(id: "replacement-request", url: "https://example.test/new") + ] + ) + + #expect(runtime.model.state == .attached) + #expect(runtime.model.pageGeneration != precedingGeneration) + let newNode = try #require(try runtime.model.domNode( + id: DOMNode.ID(DOM.Node.ID("new-node")) + )) + #expect(newNode.localName == "article") + #expect(try runtime.model.domNode(id: DOMNode.ID(DOM.Node.ID("old-node"))) == nil) + #expect(try runtime.model.networkRequest( + id: NetworkRequest.ID(Network.Request.ID("replacement-request")) + )?.state == .finished) + + await runtime.close() +} + +@MainActor +@Test +func dataKitTestingInjectsAttachmentFailureWithoutLeakingRuntimeOwnership() async { + do { + _ = try await WebInspectorDataKitTestRuntime.start( + scenario: .init( + configuration: .init(domains: [.network]), + attachFailure: .init( + domain: .network, + message: "injected Network startup failure" + ) + ), + isolation: MainActor.shared + ) + Issue.record("Expected the injected DataKit attachment failure.") + } catch let failure as WebInspectorModelContext.Failure { + guard case let .bootstrap(domain, message) = failure else { + Issue.record("Expected a bootstrap failure, got \(failure).") + return + } + #expect(domain == .network) + #expect(message.contains("injected Network startup failure")) + } catch { + Issue.record("Expected a DataKit model failure, got \(error).") + } +} diff --git a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift index db46e569..c0845457 100644 --- a/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift +++ b/Tests/WebInspectorDataKitTests/WebInspectorDataKitTests.swift @@ -3,206 +3,62 @@ import Testing @testable import WebInspectorDataKit import WebInspectorProxyKit import WebInspectorProxyKitTesting -import WebInspectorTestSupport -@MainActor -@Test -func domEventsPopulateRootAndPreserveChildIdentity() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let childID = DOM.Node.ID("child") - let grandchildID = DOM.Node.ID("grandchild") - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document", childNodeCount: 1) - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - attributes: ["class": "before"], - childNodeCount: 0 - ) - ]), - target: target +private actor ModelContextActorProbe { + private let context = WebInspectorModelContext( + configuration: .init(domains: []) ) - let child = try await waitForChild(in: context) - #expect(context.node(for: child.id) === child) - await runtime.backend.emit( - .attributeModified(childID, name: "class", value: "after"), - target: target - ) - try await waitUntil { child.attributes["class"] == "after" } - #expect(context.node(for: child.id) === child) - - await runtime.backend.emit( - .childNodeCountUpdated(childID, count: 2), - target: target - ) - try await waitUntil { child.childNodeCount == 2 } - #expect(context.node(for: child.id) === child) - - await runtime.backend.emit( - .setChildNodes(parent: childID, nodes: [ - DOM.Node(id: grandchildID, nodeType: 3, nodeName: "#text", nodeValue: "hello") - ]), - target: target - ) - try await waitUntil { - guard case let .loaded(children) = child.children else { - return false - } - return children.first?.id == DOMNode.ID(grandchildID) + func attach(to proxy: WebInspectorProxy) async throws -> WebInspectorModelContext.State { + try await context.attach(to: proxy, isolation: self) + return context.state } - guard case let .loaded(grandchildren) = child.children else { - Issue.record("Expected loaded child subtree.") - return - } - let grandchild = try #require(grandchildren.first) - context.select(grandchild) - await runtime.backend.emit( - .childNodeRemoved(parent: documentID, node: childID), - target: target - ) - try await waitUntil { - guard let root = context.rootNode, case let .loaded(children) = root.children else { - return false - } - return children.isEmpty + func close() async -> WebInspectorModelContext.State { + await context.close() + return context.state } - #expect(context.node(for: child.id) == nil) - #expect(context.node(for: grandchild.id) == nil) - #expect(context.selectedNode == nil) } @MainActor @Test -func requestChildrenDispatchesDOMCommandAndMaterializesSetChildNodes() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let childID = DOM.Node.ID("requested-child") - - await runtime.backend.enqueue((), for: "DOM", method: "requestChildNodes") +func modelContextInheritsACustomCallerActorAndDoesNotRetainIt() async throws { + try await withDataKitTestRuntime { runtime in + var probe: ModelContextActorProbe? = ModelContextActorProbe() + weak let releasedProbe = probe - await document.requestChildren(depth: 2) + let attachedState = try await probe?.attach(to: runtime.proxy) + #expect(attachedState == .attached) + #expect(await probe?.close() == .closed) - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first { - $0.domain == "DOM" && $0.method == "requestChildNodes" - }) - let payload = try #require(command.payload.cast(as: DOM.RequestChildNodesPayload.self)) - #expect(payload.id == document.id.proxyID) - #expect(payload.depth == 2) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - - try await waitUntil { - guard case let .loaded(children) = document.children else { - return false + probe = nil + for _ in 0..<100 where releasedProbe != nil { + await Task.yield() } - return children.first?.id == DOMNode.ID(childID) + #expect(releasedProbe == nil) } } @MainActor @Test -func domTreeSnapshotBuildsSelectorAndXPathFromDataKitProjection() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let documentID = DOM.Node.ID("document") - let htmlID = DOM.Node.ID("html") - let bodyID = DOM.Node.ID("body") - let firstCardID = DOM.Node.ID("first-card") - let featuredCardID = DOM.Node.ID("featured-card") - let inputID = DOM.Node.ID("search") - let textID = DOM.Node.ID("body-text") - - let document = DOM.Node( - id: documentID, - nodeType: 9, - nodeName: "#document", - children: [ - DOM.Node( - id: htmlID, - nodeType: 1, - nodeName: "HTML", - localName: "html", - children: [ - DOM.Node( - id: bodyID, - nodeType: 1, - nodeName: "BODY", - localName: "body", - children: [ - DOM.Node( - id: firstCardID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - attributes: ["class": "card"], - attributeList: [DOM.Attribute(name: "class", value: "card")] - ), - DOM.Node( - id: featuredCardID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - attributes: ["class": "card featured"], - attributeList: [DOM.Attribute(name: "class", value: "card featured")] - ), - DOM.Node( - id: inputID, - nodeType: 1, - nodeName: "INPUT", - localName: "input", - attributes: ["type": "search"], - attributeList: [DOM.Attribute(name: "type", value: "search")] - ), - DOM.Node(id: textID, nodeType: 3, nodeName: "#text", nodeValue: "hello"), - ] - ) - ] - ) - ] - ) - let (_, context) = try await startContext(runtime: runtime, document: document) - let tree = try await context.treeController() - let snapshot = tree.snapshot +func configurationNormalizesCSSAndRejectsUnconfiguredDomains() async throws { + let configuration = WebInspectorModelContext.Configuration(domains: [.css]) + #expect(configuration.domains == [.dom, .css]) + let context = WebInspectorModelContext.preview(configuration: configuration) - #expect(snapshot.selectorPath(for: DOMNode.ID(documentID)) == "") - #expect(snapshot.xPath(for: DOMNode.ID(documentID)) == "/") - #expect(snapshot.selectorPath(for: DOMNode.ID(featuredCardID)) == "body > div.featured") - #expect(snapshot.xPath(for: DOMNode.ID(featuredCardID)) == "/html/body/div[2]") - #expect(snapshot.selectorPath(for: DOMNode.ID(inputID)) == "body > input[type=\"search\"]") - #expect(snapshot.xPath(for: DOMNode.ID(textID)) == "/html/body/text()") + #expect(try context.rootDOMNode == nil) + #expect(throws: WebInspectorModelError.domainNotConfigured(.network)) { + try context.networkRequest(id: NetworkRequest.ID(Network.Request.ID("request"))) + } + await #expect(throws: WebInspectorModelError.domainNotConfigured(.console)) { + _ = try await context.consoleMessages() + } } @MainActor @Test -func domCommandsDispatchThroughDataKitContext() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() +func attachmentPublishesDOMSnapshotAndAcceptsFilteredSequenceGaps() async throws { let documentID = DOM.Node.ID("document") - let htmlID = DOM.Node.ID("html") - let bodyID = DOM.Node.ID("body") - let parentID = DOM.Node.ID("parent") let childID = DOM.Node.ID("child") let document = DOM.Node( id: documentID, @@ -210,7257 +66,1436 @@ func domCommandsDispatchThroughDataKitContext() async throws { nodeName: "#document", children: [ DOM.Node( - id: htmlID, + id: childID, nodeType: 1, - nodeName: "HTML", - localName: "html", - children: [ - DOM.Node( - id: bodyID, - nodeType: 1, - nodeName: "BODY", - localName: "body", - children: [ - DOM.Node( - id: parentID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - attributes: ["class": "card"], - attributeList: [DOM.Attribute(name: "class", value: "card")], - children: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "SPAN", - localName: "span", - attributes: ["id": "title"], - attributeList: [DOM.Attribute(name: "id", value: "title")] - ) - ] - ) - ] - ) - ] + nodeName: "DIV", + localName: "div" ) ] ) - let (target, context) = try await startContext(runtime: runtime, document: document) - let parent = try #require(context.node(for: DOMNode.ID(parentID))) - let child = try #require(context.node(for: DOMNode.ID(childID))) - - await runtime.backend.enqueue("", for: "DOM", method: "getOuterHTML") - #expect(try await child.copyText(.html) == "") - #expect(try await child.copyText(.selectorPath) == "#title") - #expect(try context.xPath(for: child) == "/html/body/div/span") - - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - try await context.dom.highlight(child.id) - - await runtime.backend.enqueue((), for: "DOM", method: "hideHighlight") - try await context.dom.hideHighlight() - - await runtime.backend.enqueue((), for: "DOM", method: "undo") - try await context.editHistory.undo() - - await runtime.backend.enqueue((), for: "DOM", method: "redo") - try await context.editHistory.redo() - - await runtime.backend.enqueue((), for: "DOM", method: "setInspectModeEnabled") - try await context.dom.setInspectMode(enabled: true) - #expect(context.isElementPickerEnabled) - - await enqueueCSSStyleReplies(on: runtime.backend) - await runtime.backend.enqueue((), for: "DOM", method: "highlightNode") - await runtime.backend.emit(.inspect(childID), target: target) - try await waitUntil { context.selectedNode === child } - try await waitUntil { child.elementStyles?.phase == .loaded } - #expect(context.isElementPickerEnabled == false) - - await runtime.backend.enqueue((), for: "DOM", method: "setAttributeValue") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.dom.setAttribute("class", value: "updated", on: parent.id) - - await runtime.backend.enqueue((), for: "DOM", method: "setOuterHTML") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.dom.setOuterHTML("", of: child.id) - - await runtime.backend.enqueue((), for: "DOM", method: "removeNode") - await runtime.backend.enqueue((), for: "DOM", method: "removeNode") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - let deletion = try await context.dom.remove([parent.id, child.id]) - #expect(deletion.acceptedNodeIDs == [child.id, parent.id]) - #expect(context.selectedNode == nil) - #expect(child.elementStyles == nil) - - await runtime.backend.enqueue((), for: "Page", method: "reload") - try await context.page.reload(ignoringCache: true) - - let commands = await runtime.backend.recordedCommands() - let outerHTML = try #require(commands.first { $0.domain == "DOM" && $0.method == "getOuterHTML" }) - #expect(outerHTML.payload.cast(as: DOM.GetOuterHTMLPayload.self)?.id == childID) - - let highlight = try #require(commands.first { $0.domain == "DOM" && $0.method == "highlightNode" }) - #expect(highlight.payload.cast(as: DOM.HighlightNodePayload.self)?.id == childID) - - #expect(commands.contains { $0.domain == "DOM" && $0.method == "undo" }) - #expect(commands.contains { $0.domain == "DOM" && $0.method == "redo" }) - - let inspectMode = try #require(commands.first { $0.domain == "DOM" && $0.method == "setInspectModeEnabled" }) - #expect(inspectMode.payload.cast(as: DOM.SetInspectModeEnabledPayload.self)?.enabled == true) - - let setAttribute = try #require(commands.first { $0.domain == "DOM" && $0.method == "setAttributeValue" }) - #expect(setAttribute.payload.cast(as: DOM.SetAttributeValuePayload.self)?.id == parentID) - #expect(setAttribute.payload.cast(as: DOM.SetAttributeValuePayload.self)?.name == "class") - #expect(setAttribute.payload.cast(as: DOM.SetAttributeValuePayload.self)?.value == "updated") - - let setOuterHTML = try #require(commands.first { $0.domain == "DOM" && $0.method == "setOuterHTML" }) - #expect(setOuterHTML.payload.cast(as: DOM.SetOuterHTMLPayload.self)?.id == childID) - #expect(setOuterHTML.payload.cast(as: DOM.SetOuterHTMLPayload.self)?.html == "") - - let removals = commands.filter { $0.domain == "DOM" && $0.method == "removeNode" } - #expect(removals.count == 2) - #expect(removals.first?.payload.cast(as: DOM.RemoveNodePayload.self)?.id == childID) - #expect(removals.last?.payload.cast(as: DOM.RemoveNodePayload.self)?.id == parentID) - let undoMarks = commands.filter { $0.domain == "DOM" && $0.method == "markUndoableState" } - #expect(undoMarks.count == 4) + try await withAttachedModelContext( + configuration: .init(domains: [.dom, .network]), + document: document + ) { fixture in + let root = try #require(try fixture.context.rootDOMNode) + #expect(root.id == DOMNode.ID(documentID)) + #expect(try fixture.context.domNode(id: DOMNode.ID(childID)) != nil) + #expect(fixture.context.state == .attached) + #expect(fixture.context.pageGeneration != nil) + + let requestID = Network.Request.ID("gap-request") + try await fixture.runtime.wire.emitRaw( + .requestWillBeSent( + id: requestID, + request: Network.Request( + id: requestID, + url: "https://example.com/gap", + method: "GET" + ), + resourceType: .fetch, + redirectResponse: nil, + timestamp: 1 + ), + target: fixture.target + ) + // CSS is not configured. This event advances the connection sequence + // without producing a model record; the next Network delta must still + // be accepted. + try await fixture.runtime.wire.emitRaw( + .mediaQueryResultChanged, + target: fixture.target + ) + try await fixture.runtime.wire.emitRaw( + .responseReceived( + id: requestID, + response: Network.Response( + url: "https://example.com/gap", + status: 204, + mimeType: "text/plain" + ), + resourceType: .fetch, + timestamp: 2 + ), + target: fixture.target + ) - let reload = try #require(commands.first { $0.domain == "Page" && $0.method == "reload" }) - #expect(reload.payload.cast(as: Page.ReloadPayload.self)?.ignoringCache == true) + try await waitUntil { + try fixture.context.networkRequest( + id: NetworkRequest.ID(requestID) + )?.status == 204 + } + #expect(fixture.context.state == .attached) + } } @MainActor @Test -func domMutationsAndUndoRedoUseOwningFrameTarget() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let frameTarget = await runtime.proxy.installTargetForTesting(kind: .frame) - let document = try #require(context.rootNode) - let scopedNodeID = DOM.Node.ID( - "frame-owned-node", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: scopedNodeID, nodeType: 3, nodeName: "#text", nodeValue: "frame") - ]), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(scopedNodeID)) != nil } - - await runtime.backend.enqueue((), for: "DOM", method: "setAttributeValue") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.dom.setAttribute("data-edited", value: "page", on: document.id) - - await runtime.backend.enqueue((), for: "DOM", method: "removeNode") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - _ = try await context.dom.remove([DOMNode.ID(scopedNodeID)]) +func attachmentDrainsLargeEnableReplayBeforePublishingReadiness() async throws { + try await withDataKitTestRuntime { runtime in + let target = try await runtime.proxy.waitForCurrentPage() + let configuration = WebInspectorModelContext.Configuration( + domains: [.network] + ) + let context = WebInspectorModelContext(configuration: configuration) + let enableGate = await runtime.wire.deferReply( + to: "Network.enable", + with: try testJSONObject(#"{}"#) + ) + let attachment = Task { + try await context.attach( + to: runtime.proxy, + isolation: MainActor.shared + ) + } + _ = await runtime.wire.observations.waitForCommands( + method: "Network.enable", + count: 1 + ) - await runtime.backend.enqueue((), for: "DOM", method: "undo") - try await context.editHistory.undo() + let replayEventCount = 512 + for index in 0.. - let consoleResults: WebInspectorFetchedResults - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - networkResults = context.fetchedResults() - consoleResults = context.fetchedResults() - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "hello", - timestamp: 2 - )), - target: target - ) - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext(id: runtimeContextID, name: "Main", kind: .normal)), - target: target +func cssPropertyIdentitySurvivesRefreshAndQueuedMutationTouchesOnlySubmittedProperty() async throws { + let bodyID = DOM.Node.ID("body") + let document = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: bodyID, + nodeType: 1, + nodeName: "BODY", + localName: "body" + ), + ] ) - try await waitUntil { - networkResults.items.count == 1 - && consoleResults.items.count == 1 - && context.executionContexts.count == 1 - } - - let request = try #require(networkResults.items.first) - let message = try #require(consoleResults.items.first) - #expect(context.rootNode?.id == DOMNode.ID(documentID)) - #expect(context.node(for: DOMNode.ID(documentID)) != nil) - #expect(context.registeredRequest(for: request.id) === request) - #expect(context.registeredMessage(for: message.id) === message) - - await enqueueDomainDisableReplies(on: runtime.backend) - await container.close() - - #expect(context.state == .detached) - #expect(context.rootNode == nil) - #expect(context.node(for: DOMNode.ID(documentID)) == nil) - #expect(context.registeredRequest(for: request.id) == nil) - #expect(networkResults.items.isEmpty) - #expect(context.registeredMessage(for: message.id) == nil) - #expect(consoleResults.items.isEmpty) - #expect(context.executionContexts.isEmpty) - #expect(context.selectedContext == nil) -} - -@MainActor -@Test -func closeRecordsNetworkDisableFailureAndDetachesContext() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await enqueueStartupReplies(on: runtime.backend) - await runtime.backend.enqueue((), for: "Console", method: "disable") - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } + try await withAttachedModelContext( + configuration: .init(domains: [.css]), + document: document + ) { fixture in + let body = try #require(try fixture.context.domNode(id: DOMNode.ID(bodyID))) + let initialStyle = cssTestStyle(margin: "0", paddingStatus: .active) + try await enqueueCSSLoadReplies(style: initialStyle, on: fixture.runtime.wire) + let styles = try await fixture.context.cssStyles(for: body) + let initialSection = try #require(styles.sections.first) + let margin = try #require(initialSection.style.properties.first { $0.name == "margin" }) + let padding = try #require(initialSection.style.properties.first { $0.name == "padding" }) + + let stalePadding = CSSStyleProperty( + id: padding.id, + name: padding.name, + value: padding.value, + text: padding.text, + status: padding.status, + isEditable: padding.isEditable + ) + await #expect(throws: WebInspectorModelError.staleModel) { + _ = try await fixture.context.setCSSProperty( + stalePadding, + enabled: false, + undo: .disabled + ) + } + #expect(stalePadding.isMutationPending == false) - await container.close() + let refreshedStyle = cssTestStyle(margin: "4px", paddingStatus: .active) + let matchedReply = await fixture.runtime.wire.deferReply( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(cssMatchedStyles(style: refreshedStyle)) + ) + await fixture.runtime.wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(.init()) + ) + await fixture.runtime.wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([]) + ) + let refresh = Task { + try await fixture.context.refreshCSSStyles(for: body) + } + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "CSS.getMatchedStylesForNode", + count: 2 + ) - #expect(context.state == .detached) - guard case .commandFailed(domain: "Network", method: "disable", message: _) = context.teardownError else { - Issue.record("Expected Network.disable teardown error.") - return + let disabledPaddingStyle = cssTestStyle(margin: "4px", paddingStatus: .disabled) + await fixture.runtime.wire.respond( + to: "CSS.setStyleText", + with: try rawCSSStyleResult(disabledPaddingStyle) + ) + let toggle = Task { + _ = try await fixture.context.setCSSProperty( + padding, + enabled: false, + undo: .disabled + ) + } + try await waitUntil { padding.isMutationPending } + + #expect(margin.isMutationPending == false) + #expect(fixture.runtime.wire.observations.commands.filter { + $0.method == "CSS.setStyleText" + }.isEmpty) + + matchedReply.open() + try await refresh.value + let setStyleCommand = await fixture.runtime.wire.observations.waitForCommands( + method: "CSS.setStyleText", + count: 1 + ).last + #expect(try setStyleCommand.map { try commandStringParameter($0, "text") } + == "margin: 4px;\n/* padding: 8px; */") + _ = try await toggle.value + + let currentSection = try #require(styles.sections.first) + let currentMargin = try #require(currentSection.style.properties.first { $0.name == "margin" }) + let currentPadding = try #require(currentSection.style.properties.first { $0.name == "padding" }) + #expect(currentMargin === margin) + #expect(currentPadding === padding) + #expect(margin.value == "4px") + #expect(margin.status == .active) + #expect(margin.isMutationPending == false) + #expect(padding.status == .disabled) + #expect(padding.text == "/* padding: 8px; */") + #expect(padding.isMutationPending == false) + + let structurallyChangedStyle = cssTestStyleWithLeadingColor() + styles.load( + matchedStyles: cssMatchedStyles(style: structurallyChangedStyle), + inlineStyles: .init(), + computedProperties: [] + ) + let changedSection = try #require(styles.sections.first) + let replacementAtOldMarginID = try #require(changedSection.style.properties.first) + let shiftedMargin = try #require( + changedSection.style.properties.first { $0.name == "margin" } + ) + #expect(replacementAtOldMarginID.id == margin.id) + #expect(replacementAtOldMarginID.name == "color") + #expect(replacementAtOldMarginID !== margin) + #expect(shiftedMargin !== margin) + await #expect(throws: WebInspectorModelError.staleModel) { + _ = try await fixture.context.setCSSProperty( + margin, + enabled: false, + undo: .disabled + ) + } } } @MainActor @Test -func restartDisablesPreviousDomainTrackingBeforeReenable() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("document-1"), nodeType: 9, nodeName: "#document") +func cancellingCSSLoadingRestoresRefreshableResourcePhase() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("cancelled-node")), + modelContext: context ) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("document-1")) } + styles.cancelLoading() + #expect(styles.phase == .unavailable) - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("document-2"), nodeType: 9, nodeName: "#document") + styles.load( + matchedStyles: .init(), + inlineStyles: .init(), + computedProperties: [] ) + styles.markLoading() + styles.cancelLoading() - context.start() - - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("document-2")) } - - let commands = await runtime.backend.recordedCommands() - #expect(commands == startupCommands + shutdownCommands + startupCommands) + #expect(styles.phase == .needsRefresh) + #expect(styles.sections.isEmpty) } @MainActor @Test -func restartWaitsForPreviousStartupCleanupBeforeReenable() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = WebInspectorTestGate() - - await runtime.backend.hold(domain: "Network", method: "enable", gate: enableGate) - await enqueueDomainEnableReplies(on: runtime.backend) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Network", method: "enable"), - ] - } - - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") +func cancellingQueuedCSSOperationDoesNotChangeActiveLoadingPhase() async throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("queued-cancellation-node")), + modelContext: context ) + let holderEntered = DataKitRawWireGate() + let releaseHolder = DataKitRawWireGate() + let holder = Task { + try await styles.withExclusiveOperation { + holderEntered.open() + await releaseHolder.waiter.wait() + } + } + await holderEntered.waiter.wait() - context.start() - - await enableGate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } - - let commands = await runtime.backend.recordedCommands() - #expect(commands == Array(startupCommands.prefix(4)) + [ - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Network", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ] + startupCommands) -} - -@MainActor -@Test -func runtimeEnableReplayIsCapturedBeforeCommandReturns() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = WebInspectorTestGate() - let contextID = Runtime.ExecutionContext.ID("main") - - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: enableGate) - await enqueueStartupReplies(on: runtime.backend) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable") - ] + let queued = Task { + try await styles.withExclusiveOperation {} } + await Task.yield() + queued.cancel() - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext(id: contextID, name: "Main", kind: .normal)), - target: target - ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(contextID) + await #expect(throws: CancellationError.self) { + try await queued.value } + #expect(styles.phase == .loading) - await enableGate.open() - try await waitUntil { context.state == .attached } - #expect(context.selectedContext?.id == RuntimeContext.ID(contextID)) + releaseHolder.open() + try await holder.value } @MainActor @Test -func consoleEnableReplayIsCapturedBeforeCommandReturns() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let enableGate = WebInspectorTestGate() +func cancellingInFlightCSSLoadPreservesCancellationAndResourcePhase() async throws { + let bodyID = DOM.Node.ID("cancelled-css-body") + let document = DOM.Node( + id: DOM.Node.ID("cancelled-css-document"), + nodeType: 9, + nodeName: "#document", + children: [ + DOM.Node( + id: bodyID, + nodeType: 1, + nodeName: "BODY", + localName: "body" + ), + ] + ) + try await withAttachedModelContext( + configuration: .init(domains: [.css]), + document: document + ) { fixture in + let body = try #require(try fixture.context.domNode(id: DOMNode.ID(bodyID))) + let gate = await fixture.runtime.wire.deferReply( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(.init()) + ) + let load = Task { + _ = try await fixture.context.cssStyles(for: body) + } + _ = await fixture.runtime.wire.observations.waitForCommands( + method: "CSS.getMatchedStylesForNode", + count: 1 + ) - await runtime.backend.hold(domain: "Console", method: "enable", gate: enableGate) - await enqueueStartupReplies(on: runtime.backend) + load.cancel() + gate.open() - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands + await #expect(throws: CancellationError.self) { + try await load.value + } + #expect(body.elementStyles?.phase == .unavailable) } - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "replayed" - )), - target: target - ) - try await waitUntil { results.items.map(\.text) == ["replayed"] } - - await enableGate.open() - try await waitUntil { context.state == .attached } - #expect(results.items.map(\.text) == ["replayed"]) } @MainActor @Test -func startupRefetchesDocumentWhenMainFrameNavigatesBeforeAttach() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let consoleGate = WebInspectorTestGate() - let staleDocumentID = DOM.Node.ID("stale-startup-document") - let freshDocumentID = DOM.Node.ID("fresh-startup-document") - - await runtime.backend.hold(domain: "Console", method: "enable", gate: consoleGate) - await runtime.backend.enqueue((), for: "Inspector", method: "enable") - await runtime.backend.enqueue((), for: "Inspector", method: "initialized") - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await runtime.backend.enqueue((), for: "Network", method: "enable") - await runtime.backend.enqueue( - DOM.Node(id: staleDocumentID, nodeType: 9, nodeName: "#document"), - for: "DOM", - method: "getDocument" +func cssTopologyChangePreservesInspectorBaselineForUnrelatedStyle() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("baseline-node")), + modelContext: context ) - await runtime.backend.enqueue( - DOM.Node(id: freshDocumentID, nodeType: 9, nodeName: "#document"), - for: "DOM", - method: "getDocument" + let editedBefore = cssBaselineTestStyle( + id: "edited-style", + properties: [("color", "red")] ) - await runtime.backend.enqueue((), for: "Console", method: "enable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands - } - - await runtime.backend.emit( - .frameNavigated(WebInspectorPageFrameLifecycle( - id: FrameID("main-frame"), - parentID: nil, - loaderID: "loader-2", - name: "Main", - url: "https://example.test/next", - securityOrigin: "https://example.test", - mimeType: "text/html" - )), - target: target - ) - await consoleGate.open() - - try await waitUntil { context.rootNode?.id == DOMNode.ID(freshDocumentID) } - #expect(context.state == .attached) - #expect(context.node(for: DOMNode.ID(staleDocumentID)) == nil) - #expect(await runtime.backend.recordedCommands() == startupCommands + [ - RecordedCommand(domain: "DOM", method: "getDocument") - ]) -} - -@MainActor -@Test -func transportBackedStartupCapturesRuntimeAndConsoleReplayBeforeEnableReplies() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installTransportPageTarget(in: transport, targetID: ProtocolTarget.ID("page-main")) - let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.id == .currentPage) - let container = WebInspectorContainer(proxy: proxy) - let context = container.mainContext - let consoleResults: WebInspectorFetchedResults = context.fetchedResults() - - try await replyTransportInspectorInitialization(backend, transport: transport, targetID: ProtocolTarget.ID("page-main")) - - let runtimeEnable = try await waitForTransportTargetMessage(backend, method: "Runtime.enable") - await receiveTransportTargetEvent( - transport, - targetID: runtimeEnable.targetIdentifier, - method: "Runtime.executionContextCreated", - params: #"{"context":{"id":11,"name":"Main","frameId":"main-frame","type":"normal"}}"# + let unrelatedBefore = cssBaselineTestStyle( + id: "unrelated-style", + properties: [("margin", "0")] ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(Runtime.ExecutionContext.ID("11")) - } - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" + styles.load( + matchedStyles: cssBaselineMatchedStyles([editedBefore, unrelatedBefore]), + inlineStyles: .init(), + computedProperties: [] ) - - let networkEnable = try await waitForTransportTargetMessage(backend, method: "Network.enable") - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" + let editedProperty = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "color" } ) - let getDocument = try await waitForTransportTargetMessage(backend, method: "DOM.getDocument") - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: ##"{"root":{"nodeId":1,"nodeType":9,"nodeName":"#document","localName":"","nodeValue":"","frameId":"main-frame","childNodeCount":0}}"## + let editedAfter = cssBaselineTestStyle( + id: "edited-style", + properties: [("color", "blue")] ) + styles.applySetStyleText(result: editedAfter, for: editedProperty.id) + #expect(editedProperty.isModifiedByInspector) - let consoleEnable = try await waitForTransportTargetMessage(backend, method: "Console.enable") - await receiveTransportTargetEvent( - transport, - targetID: consoleEnable.targetIdentifier, - method: "Console.messageAdded", - params: #"{"message":{"source":"console-api","level":"warning","text":"replayed","repeatCount":1}}"# + let unrelatedAfter = cssBaselineTestStyle( + id: "unrelated-style", + properties: [("display", "block"), ("margin", "0")] ) - try await waitUntil { consoleResults.items.map(\.text) == ["replayed"] } - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" + styles.load( + matchedStyles: cssBaselineMatchedStyles([editedAfter, unrelatedAfter]), + inlineStyles: .init(), + computedProperties: [] ) - try await waitUntil { context.state == .attached } - #expect(context.rootNode?.id == DOMNode.ID(DOM.Node.ID("1"))) - #expect(context.selectedContext?.id == RuntimeContext.ID(Runtime.ExecutionContext.ID("11"))) - #expect(consoleResults.items.map(\.text) == ["replayed"]) + #expect(editedProperty.isModifiedByInspector) + #expect( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "color" } === editedProperty + ) } @MainActor @Test -func transportBackedInspectorInspectMaterializesSelectionAndRestoresHighlight() async throws { - let targetID = ProtocolTarget.ID("page-main") - let inspectedID = DOM.Node.ID("42") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: targetID, - documentID: "1" +func cssTopologyExpansionRekeysUniqueInspectorBaselinesWithinStyle() throws { + let context = WebInspectorModelContext.preview() + let styles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("expanded-baseline-node")), + modelContext: context ) - let startupMessageCount = await backend.sentTargetMessages().count - - let enablePickerTask = Task { @MainActor in - try await context.setElementPickerEnabled(true) - } - let inspectMode = try await waitForTransportTargetMessage( - backend, - method: "DOM.setInspectModeEnabled", - after: startupMessageCount + let initialStyle = cssBaselineTestStyle( + id: "expanded-style", + properties: [("inset", "0"), ("height", "100%"), ("opacity", "0")] ) - #expect(inspectMode.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: inspectMode.targetIdentifier, - messageID: try transportMessageID(inspectMode.message), - result: "{}" + styles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] ) - try await enablePickerTask.value - #expect(context.isElementPickerEnabled) - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "Inspector.inspect", - params: #"{"object":{"type":"object","subtype":"node","objectId":"node-object"}}"# + let height = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "height" } ) - let requestNode = try await waitForTransportTargetMessage( - backend, - method: "DOM.requestNode", - after: startupMessageCount + let heightEditedStyle = cssBaselineTestStyle( + id: "expanded-style", + properties: [("inset", "0"), ("height", "50%"), ("opacity", "0")] ) - #expect(requestNode.targetIdentifier == targetID) - #expect(try transportTargetMessageParameters(requestNode.message)["objectId"] as? String == "node-object") + styles.applySetStyleText(result: heightEditedStyle, for: height.id) + #expect(height.isModifiedByInspector) - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "DOM.setChildNodes", - params: ##"{"parentId":"1","nodes":[{"nodeId":42,"nodeType":1,"nodeName":"DIV","localName":"div","nodeValue":"","childNodeCount":0}]}"## + let inset = try #require( + styles.sections + .flatMap(\.style.properties) + .first { $0.name == "inset" } ) - await receiveTransportTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try transportMessageID(requestNode.message), - result: #"{"nodeId":42}"# + let expandedStyle = cssBaselineTestStyle( + id: "expanded-style", + properties: [ + ("inset", "1px"), + ("top", "1px"), + ("right", "1px"), + ("bottom", "1px"), + ("left", "1px"), + ("height", "50%"), + ("opacity", "0"), + ] ) + styles.applySetStyleText(result: expandedStyle, for: inset.id) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(inspectedID) } - #expect(context.isElementPickerEnabled == false) + let editedProperties = styles.sections.flatMap(\.style.properties) + #expect(editedProperties.first { $0.name == "inset" }?.isModifiedByInspector == true) + #expect(editedProperties.first { $0.name == "height" }?.isModifiedByInspector == true) - let highlight = try await waitForTransportTargetMessage( - backend, - method: "DOM.highlightNode", - after: startupMessageCount - ) - #expect(highlight.targetIdentifier == targetID) - #expect((try transportTargetMessageParameters(highlight.message)["nodeId"] as? NSNumber)?.intValue == 42) - await receiveTransportTargetReply( - transport, - targetID: highlight.targetIdentifier, - messageID: try transportMessageID(highlight.message), - result: "{}" + styles.load( + matchedStyles: cssBaselineMatchedStyles([expandedStyle]), + inlineStyles: .init(), + computedProperties: [] ) + let refreshedProperties = styles.sections.flatMap(\.style.properties) + #expect(refreshedProperties.first { $0.name == "inset" }?.isModifiedByInspector == true) + #expect(refreshedProperties.first { $0.name == "height" }?.isModifiedByInspector == true) +} - let matchedStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getMatchedStylesForNode", - after: startupMessageCount +@MainActor +@Test +func sharedRuleInspectorBaselineFollowsStyleAcrossDOMNodes() throws { + let context = WebInspectorModelContext.preview() + let firstStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("first-shared-style-node")), + modelContext: context ) - #expect(matchedStyles.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: matchedStyles.targetIdentifier, - messageID: try transportMessageID(matchedStyles.message), - result: #"{"matchedCSSRules":[],"inherited":[],"pseudoElements":[]}"# + let secondStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("second-shared-style-node")), + modelContext: context ) + let initialStyle = cssTestStyle(margin: "0", paddingStatus: .active) + for styles in [firstStyles, secondStyles] { + styles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] + ) + } - let inlineStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getInlineStylesForNode", - after: startupMessageCount - ) - #expect(inlineStyles.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: inlineStyles.targetIdentifier, - messageID: try transportMessageID(inlineStyles.message), - result: "{}" + let firstPadding = try #require( + firstStyles.sections + .flatMap(\.style.properties) + .first { $0.name == "padding" } ) + let editedStyle = cssTestStyle(margin: "0", paddingStatus: .disabled) + firstStyles.applySetStyleText(result: editedStyle, for: firstPadding.id) + #expect(firstPadding.status == .disabled) + #expect(firstPadding.isModifiedByInspector) - let computedStyle = try await waitForTransportTargetMessage( - backend, - method: "CSS.getComputedStyleForNode", - after: startupMessageCount + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([editedStyle]), + inlineStyles: .init(), + computedProperties: [] ) - #expect(computedStyle.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: computedStyle.targetIdentifier, - messageID: try transportMessageID(computedStyle.message), - result: #"{"computedStyle":[]}"# + let secondPadding = try #require( + secondStyles.sections + .flatMap(\.style.properties) + .first { $0.name == "padding" } ) + #expect(secondPadding !== firstPadding) + #expect(secondPadding.status == .disabled) + #expect(secondPadding.isModifiedByInspector) - try await waitUntil { context.selectedNode?.elementStyles?.phase == .loaded } -} - -@MainActor -@Test -func transportBackedFrameNavigationClearsRestoredPickerHighlight() async throws { - let targetID = ProtocolTarget.ID("page-main") - let inspectedID = DOM.Node.ID("42") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: targetID, - documentID: "1" - ) - let startupMessageCount = await backend.sentTargetMessages().count + secondStyles.applySetStyleText(result: initialStyle, for: secondPadding.id) + #expect(secondPadding.status == .active) + #expect(secondPadding.isModifiedByInspector == false) - let enablePickerTask = Task { @MainActor in - try await context.setElementPickerEnabled(true) - } - let inspectMode = try await waitForTransportTargetMessage( - backend, - method: "DOM.setInspectModeEnabled", - after: startupMessageCount + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] ) - await receiveTransportTargetReply( - transport, - targetID: inspectMode.targetIdentifier, - messageID: try transportMessageID(inspectMode.message), - result: "{}" + #expect( + firstStyles.sections + .flatMap(\.style.properties) + .first { $0.name == "padding" }? + .isModifiedByInspector == false ) - try await enablePickerTask.value +} - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "Inspector.inspect", - params: #"{"object":{"type":"object","subtype":"node","objectId":"node-object"}}"# - ) - let requestNode = try await waitForTransportTargetMessage( - backend, - method: "DOM.requestNode", - after: startupMessageCount - ) - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "DOM.setChildNodes", - params: ##"{"parentId":"1","nodes":[{"nodeId":42,"nodeType":1,"nodeName":"DIV","localName":"div","nodeValue":"","childNodeCount":0}]}"## +@MainActor +@Test +func staleSharedRuleLoadCannotRetireInspectorBaseline() throws { + let context = WebInspectorModelContext.preview() + let editedStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("edited-shared-style-node")), + modelContext: context ) - await receiveTransportTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try transportMessageID(requestNode.message), - result: #"{"nodeId":42}"# + let staleStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("stale-shared-style-node")), + modelContext: context ) - try await waitUntil { context.selectedNode?.id == DOMNode.ID(inspectedID) } - - let highlight = try await waitForTransportTargetMessage( - backend, - method: "DOM.highlightNode", - after: startupMessageCount + let initialStyle = cssBaselineTestStyle( + id: "stale-shared-style", + properties: [("color", "red")] ) - await receiveTransportTargetReply( - transport, - targetID: highlight.targetIdentifier, - messageID: try transportMessageID(highlight.message), - result: "{}" + editedStyles.load( + matchedStyles: cssBaselineMatchedStyles([initialStyle]), + inlineStyles: .init(), + computedProperties: [] ) - - let beforeNavigationMessageCount = await backend.sentTargetMessages().count - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "Page.frameNavigated", - params: #"{"frame":{"id":"main-frame","loaderId":"loader-2","name":"Main","url":"https://example.test/next","securityOrigin":"https://example.test","mimeType":"text/html"}}"# + let editedColor = try #require(editedStyles.sections.first?.style.properties.first) + let editedStyle = cssBaselineTestStyle( + id: "stale-shared-style", + properties: [("color", "blue")] ) + editedStyles.applySetStyleText(result: editedStyle, for: editedColor.id) - let hideHighlight = try await waitForTransportTargetMessage( - backend, - method: "DOM.hideHighlight", - after: beforeNavigationMessageCount + let staleResponseStyle = cssBaselineTestStyle( + id: "stale-shared-style", + properties: [("display", "block"), ("color", "red")] ) - #expect(hideHighlight.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: hideHighlight.targetIdentifier, - messageID: try transportMessageID(hideHighlight.message), - result: "{}" - ) - - let getDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: beforeNavigationMessageCount + staleStyles.load( + matchedStyles: cssBaselineMatchedStyles([staleResponseStyle]), + inlineStyles: .init(), + computedProperties: [] ) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "2") + editedStyles.load( + matchedStyles: cssBaselineMatchedStyles([editedStyle]), + inlineStyles: .init(), + computedProperties: [] ) - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("2")) } - #expect(context.selectedNode == nil) + #expect(staleStyles.sections.first?.style.properties.first?.isModifiedByInspector == false) + #expect(editedStyles.sections.first?.style.properties.first?.isModifiedByInspector == true) } @MainActor @Test -func transportBackedFrameInspectProjectsFrameDocumentUnderIframeOwner() async throws { - let pageTargetID = ProtocolTarget.ID("page-main") - let frameTargetID = ProtocolTarget.ID("frame-child") - let iframeOwnerID = DOMNode.ID(DOM.Node.ID("iframe-owner")) - let scopedFrameDocumentID = DOMNode.ID(DOM.Node.ID("frame-document", scopedToTargetRawValue: frameTargetID.rawValue)) - let scopedInspectedID = DOMNode.ID(DOM.Node.ID("42", scopedToTargetRawValue: frameTargetID.rawValue)) - let (backend, transport, context) = try await startTransportBackedContext( - targetID: pageTargetID, - documentID: "1" +func inspectorBaselinesDoNotAliasTargetScopedStyleIDs() throws { + let context = WebInspectorModelContext.preview() + let firstStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("first-target-node")), + modelContext: context ) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - let startupMessageCount = await backend.sentTargetMessages().count - - await receiveTransportTargetEvent( - transport, - targetID: pageTargetID, - method: "DOM.setChildNodes", - params: ##"{"parentId":"1","nodes":[{"nodeId":"iframe-owner","nodeType":1,"nodeName":"IFRAME","localName":"iframe","nodeValue":"","frameId":"child-frame","childNodeCount":0,"attributes":["src","https://child.example.test/"]}]}"## + let secondStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("second-target-node")), + modelContext: context ) - try await waitUntil { context.node(for: iframeOwnerID) != nil } - - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-child","type":"page","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# + let firstInitial = cssBaselineTestStyle( + id: CSS.Style.ID("shared-style", scopedToTargetRawValue: "frame-a"), + properties: [("color", "red")] ) - await receiveTransportTargetEvent( - transport, - targetID: frameTargetID, - method: "Inspector.inspect", - params: #"{"object":{"type":"object","subtype":"node","objectId":"frame-node-object"}}"# + let secondInitial = cssBaselineTestStyle( + id: CSS.Style.ID("shared-style", scopedToTargetRawValue: "frame-b"), + properties: [("color", "red")] ) - - let requestNode = try await waitForTransportTargetMessage( - backend, - method: "DOM.requestNode", - after: startupMessageCount + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstInitial]), + inlineStyles: .init(), + computedProperties: [] ) - #expect(requestNode.targetIdentifier == pageTargetID) - #expect(try transportTargetMessageParameters(requestNode.message)["objectId"] as? String == "frame-node-object") - let afterRequestNodeCount = await backend.sentTargetMessages().count - await receiveTransportTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try transportMessageID(requestNode.message), - result: #"{"nodeId":42}"# + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondInitial]), + inlineStyles: .init(), + computedProperties: [] ) - let frameGetDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: afterRequestNodeCount - ) - #expect(frameGetDocument.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: frameGetDocument.targetIdentifier, - messageID: try transportMessageID(frameGetDocument.message), - result: ##"{"root":{"nodeId":"frame-document","nodeType":9,"nodeName":"#document","localName":"","nodeValue":"","frameId":"child-frame","documentURL":"https://child.example.test/","childNodeCount":1,"children":[{"nodeId":"frame-html","nodeType":1,"nodeName":"HTML","localName":"html","nodeValue":"","childNodeCount":1,"children":[{"nodeId":"frame-body","nodeType":1,"nodeName":"BODY","localName":"body","nodeValue":"","childNodeCount":1,"children":[{"nodeId":42,"nodeType":1,"nodeName":"BUTTON","localName":"button","nodeValue":"","childNodeCount":0}]}]}]}}"## + let firstColor = try #require(firstStyles.sections.first?.style.properties.first) + let firstEdited = cssBaselineTestStyle( + id: CSS.Style.ID("shared-style", scopedToTargetRawValue: "frame-a"), + properties: [("color", "blue")] ) + firstStyles.applySetStyleText(result: firstEdited, for: firstColor.id) - try await waitUntil { - controller.snapshot.selectedNodeID == scopedInspectedID - && controller.snapshot.parent(of: scopedFrameDocumentID) == iframeOwnerID - } - try await waitUntil { - recorder.updates.contains { update in - guard case let .delta(delta) = update else { - return false - } - return delta == .childrenReplaced(parentID: iframeOwnerID, childIDs: [scopedFrameDocumentID]) - } - } - let snapshot = controller.snapshot - let iframe = try #require(context.node(for: iframeOwnerID)) - #expect(iframe.contentDocument?.id == scopedFrameDocumentID) - #expect(snapshot.visibleChildren(of: iframeOwnerID).nodeIDs == [scopedFrameDocumentID]) - #expect(snapshot.ancestorNodeIDs(of: scopedInspectedID).contains(iframeOwnerID)) + let secondColor = try #require(secondStyles.sections.first?.style.properties.first) + #expect(firstColor.isModifiedByInspector) + #expect(secondColor.isModifiedByInspector == false) +} - let matchedStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getMatchedStylesForNode", - after: startupMessageCount - ) - #expect(matchedStyles.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: matchedStyles.targetIdentifier, - messageID: try transportMessageID(matchedStyles.message), - result: #"{"matchedCSSRules":[],"inherited":[],"pseudoElements":[]}"# +@MainActor +@Test +func resettingFrameTargetRetiresOnlyItsInspectorBaselines() throws { + let context = WebInspectorModelContext.preview() + let firstTargetID = WebInspectorTarget.ID("frame-a") + let secondTargetID = WebInspectorTarget.ID("frame-b") + let firstStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("first-frame-node")), + modelContext: context ) - - let inlineStyles = try await waitForTransportTargetMessage( - backend, - method: "CSS.getInlineStylesForNode", - after: startupMessageCount + let secondStyles = CSSStyles( + nodeID: DOMNode.ID(DOM.Node.ID("second-frame-node")), + modelContext: context ) - #expect(inlineStyles.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: inlineStyles.targetIdentifier, - messageID: try transportMessageID(inlineStyles.message), - result: "{}" + let firstInitial = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: firstTargetID.rawValue), + properties: [("color", "red")] ) - - let computedStyle = try await waitForTransportTargetMessage( - backend, - method: "CSS.getComputedStyleForNode", - after: startupMessageCount + let secondInitial = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: secondTargetID.rawValue), + properties: [("color", "red")] ) - #expect(computedStyle.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: computedStyle.targetIdentifier, - messageID: try transportMessageID(computedStyle.message), - result: #"{"computedStyle":[]}"# + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstInitial]), + inlineStyles: .init(), + computedProperties: [] ) - - try await waitUntil { context.selectedNode?.elementStyles?.phase == .loaded } -} - -@MainActor -@Test -func transportBackedFrameRuntimeAndConsoleEventsKeepTargetScope() async throws { - let pageTargetID = ProtocolTarget.ID("page-main") - let frameTargetID = ProtocolTarget.ID("frame-runtime") - let frameTarget = WebInspectorTarget.ID(frameTargetID.rawValue) - let scopedContextID = Runtime.ExecutionContext.ID("7", scopedToTargetRawValue: frameTargetID.rawValue) - let (backend, transport, context) = try await startTransportBackedContext( - targetID: pageTargetID, - documentID: "1" + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondInitial]), + inlineStyles: .init(), + computedProperties: [] ) - let consoleResults: WebInspectorFetchedResults = context.fetchedResults() - let startupMessageCount = await backend.sentTargetMessages().count - - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-runtime","type":"page","frameId":"frame-runtime","parentFrameId":"main-frame","isProvisional":false}}}"# + let firstColor = try #require(firstStyles.sections.first?.style.properties.first) + let secondColor = try #require(secondStyles.sections.first?.style.properties.first) + let firstEdited = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: firstTargetID.rawValue), + properties: [("color", "blue")] ) - await receiveTransportTargetEvent( - transport, - targetID: frameTargetID, - method: "Runtime.executionContextCreated", - params: #"{"context":{"id":7,"name":"Frame","frameId":"frame-runtime","type":"normal"}}"# + let secondEdited = cssBaselineTestStyle( + id: CSS.Style.ID("reset-style", scopedToTargetRawValue: secondTargetID.rawValue), + properties: [("color", "blue")] ) - try await waitUntil { - context.executionContexts.contains { $0.id == RuntimeContext.ID(scopedContextID) } - } - let frameContext = try #require(context.executionContexts.first { $0.id == RuntimeContext.ID(scopedContextID) }) + firstStyles.applySetStyleText(result: firstEdited, for: firstColor.id) + secondStyles.applySetStyleText(result: secondEdited, for: secondColor.id) - var capturedEvaluation: RuntimeEvaluation? - let evaluationTask = Task { @MainActor in - capturedEvaluation = try await context.runtime.evaluate("window", in: frameContext) - } - let evaluate = try await waitForTransportTargetMessage( - backend, - method: "Runtime.evaluate", - after: startupMessageCount + context.cssInspectorBaselineStore.reset(targetID: firstTargetID) + firstStyles.load( + matchedStyles: cssBaselineMatchedStyles([firstEdited]), + inlineStyles: .init(), + computedProperties: [] ) - #expect(evaluate.targetIdentifier == frameTargetID) - let evaluateParameters = try transportTargetMessageParameters(evaluate.message) - #expect(evaluateParameters["contextId"] as? Int == 7) - await receiveTransportTargetReply( - transport, - targetID: evaluate.targetIdentifier, - messageID: try transportMessageID(evaluate.message), - result: #"{"result":{"type":"object","objectId":"frame-evaluation-object","description":"frame object"}}"# + secondStyles.load( + matchedStyles: cssBaselineMatchedStyles([secondEdited]), + inlineStyles: .init(), + computedProperties: [] ) - try await evaluationTask.value - let evaluation = try #require(capturedEvaluation) - #expect(evaluation.object.proxyID?.targetScopeRawValue == frameTargetID.rawValue) - #expect(evaluation.object.proxyID?.unscopedRawValue == "frame-evaluation-object") - await receiveTransportTargetEvent( - transport, - targetID: frameTargetID, - method: "Console.messageAdded", - params: #"{"message":{"source":"console-api","level":"log","text":"frame log","networkRequestId":"frame-request-77","parameters":[{"type":"object","objectId":"frame-console-object","description":"console object"}],"repeatCount":1}}"# - ) - try await waitUntil { consoleResults.items.map(\.text) == ["frame log"] } - let consoleMessage = try #require(consoleResults.items.first) - #expect(consoleMessage.targetID == frameTarget) - #expect(consoleMessage.networkRequestID == NetworkRequest.ID( - Network.Request.ID("frame-request-77", scopedToTargetRawValue: frameTargetID.rawValue) - )) - let consoleObject = try #require(consoleMessage.parameters.first) - #expect(consoleObject.proxyID?.targetScopeRawValue == frameTargetID.rawValue) - #expect(consoleObject.proxyID?.unscopedRawValue == "frame-console-object") + #expect(firstStyles.sections.first?.style.properties.first?.isModifiedByInspector == false) + #expect(secondStyles.sections.first?.style.properties.first?.isModifiedByInspector == true) } -@MainActor -@Test -func consoleMessagesClearedForDistinctTargetsReleasesBothObjectGroups() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (_, context) = try await startContext(runtime: runtime) - await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") - await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") +private struct AttachedModelFixture { + let runtime: DataKitTestRuntime + let target: WebInspectorTarget + let context: WebInspectorModelContext + let configuration: WebInspectorModelContext.Configuration +} - context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-a") - ) - context.apply( - Console.Event.messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - targetID: WebInspectorTarget.ID("console-frame-b") +private func cssTestStyle( + margin: String, + paddingStatus: CSS.Status +) -> CSS.Style { + let styleID = "test-style\u{1F}0" + let paddingText = paddingStatus == .disabled + ? "/* padding: 8px; */" + : "padding: 8px;" + return CSS.Style( + id: CSS.Style.ID(styleID), + properties: [ + CSS.Property( + id: CSS.Property.ID("\(styleID)\u{1F}0"), + name: "margin", + value: margin, + text: "margin: \(margin);", + status: .active, + isEditable: true + ), + CSS.Property( + id: CSS.Property.ID("\(styleID)\u{1F}1"), + name: "padding", + value: "8px", + text: paddingText, + status: paddingStatus, + isEditable: true + ), + ], + cssText: "margin: \(margin);\n\(paddingText)", + isEditable: true ) +} - try await waitUntil { - await runtime.backend.recordedCommands().filter { - $0 == RecordedCommand(domain: "Runtime", method: "releaseObjectGroup") - }.count == 2 - } +private func cssMatchedStyles(style: CSS.Style) -> CSS.MatchedStyles { + CSS.MatchedStyles(matchedRules: [ + CSS.Rule( + id: CSS.Rule.ID("test-rule\u{1F}0"), + selectorList: CSS.Rule.SelectorList(selectors: ["body"], text: "body"), + origin: CSS.Origin(rawValue: "author"), + style: style + ), + ]) } -@MainActor -@Test -func transportBackedStyleSheetTextEditRoutesToFrameTargetAndMarksUndo() async throws { - let pageTargetID = ProtocolTarget.ID("page-main") - let frameTargetID = ProtocolTarget.ID("frame-css") - let styleSheetID = CSS.StyleSheet.ID("frame-sheet", scopedToTargetRawValue: frameTargetID.rawValue) - let (backend, transport, context) = try await startTransportBackedContext( - targetID: pageTargetID, - documentID: "1" +private func cssTestStyleWithLeadingColor() -> CSS.Style { + let styleID = "test-style\u{1F}0" + return CSS.Style( + id: CSS.Style.ID(styleID), + properties: [ + CSS.Property( + id: CSS.Property.ID("\(styleID)\u{1F}0"), + name: "color", + value: "red", + text: "color: red;", + status: .active, + isEditable: true + ), + CSS.Property( + id: CSS.Property.ID("\(styleID)\u{1F}1"), + name: "margin", + value: "4px", + text: "margin: 4px;", + status: .active, + isEditable: true + ), + CSS.Property( + id: CSS.Property.ID("\(styleID)\u{1F}2"), + name: "padding", + value: "8px", + text: "/* padding: 8px; */", + status: .disabled, + isEditable: true + ), + ], + cssText: "color: red;\nmargin: 4px;\n/* padding: 8px; */", + isEditable: true ) - let startupMessageCount = await backend.sentTargetMessages().count +} - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-css","type":"page","frameId":"frame-css","parentFrameId":"main-frame","isProvisional":false}}}"# - ) +private func cssBaselineTestStyle( + id: String, + properties: [(name: String, value: String)] +) -> CSS.Style { + cssBaselineTestStyle(id: CSS.Style.ID(id), properties: properties) +} - let editTask = Task { @MainActor in - try await context.css.setStyleSheetText("body { color: red; }", for: styleSheetID) - } - let setStyleSheetText = try await waitForTransportTargetMessage( - backend, - method: "CSS.setStyleSheetText", - after: startupMessageCount +private func cssBaselineTestStyle( + id: CSS.Style.ID, + properties: [(name: String, value: String)] +) -> CSS.Style { + let rawID = id.rawValue + return CSS.Style( + id: id, + properties: properties.enumerated().map { index, property in + CSS.Property( + id: CSS.Property.ID("\(rawID)\u{1F}\(index)"), + name: property.name, + value: property.value, + text: "\(property.name): \(property.value);", + status: .active, + isEditable: true + ) + }, + cssText: properties + .map { "\($0.name): \($0.value);" } + .joined(separator: "\n"), + isEditable: true ) - #expect(setStyleSheetText.targetIdentifier == frameTargetID) - let parameters = try transportTargetMessageParameters(setStyleSheetText.message) - #expect(parameters["styleSheetId"] as? String == "frame-sheet") - #expect(parameters["text"] as? String == "body { color: red; }") - await receiveTransportTargetReply( - transport, - targetID: setStyleSheetText.targetIdentifier, - messageID: try transportMessageID(setStyleSheetText.message), - result: "{}" +} + +private func cssBaselineMatchedStyles(_ styles: [CSS.Style]) -> CSS.MatchedStyles { + CSS.MatchedStyles( + matchedRules: styles.enumerated().map { index, style in + CSS.Rule( + id: CSS.Rule.ID("baseline-rule-\(index)"), + selectorList: CSS.Rule.SelectorList( + selectors: [".baseline-\(index)"], + text: ".baseline-\(index)" + ), + origin: CSS.Origin(rawValue: "author"), + style: style + ) + } ) +} - let markUndoableState = try await waitForTransportTargetMessage( - backend, - method: "DOM.markUndoableState", - after: startupMessageCount +private func enqueueCSSLoadReplies( + style: CSS.Style, + on wire: DataKitRawWireDriver +) async throws { + await wire.respond( + to: "CSS.getMatchedStylesForNode", + with: try rawCSSMatchedStylesResult(cssMatchedStyles(style: style)) + ) + await wire.respond( + to: "CSS.getInlineStylesForNode", + with: try rawCSSInlineStylesResult(.init()) ) - #expect(markUndoableState.targetIdentifier == frameTargetID) - await receiveTransportTargetReply( - transport, - targetID: markUndoableState.targetIdentifier, - messageID: try transportMessageID(markUndoableState.message), - result: "{}" + await wire.respond( + to: "CSS.getComputedStyleForNode", + with: try rawCSSComputedStyleResult([]) ) - try await editTask.value } @MainActor -@Test -func currentPageCommitRetargetsDataKitStateToNewTransportTarget() async throws { - let oldTargetID = ProtocolTarget.ID("page-old") - let newTargetID = ProtocolTarget.ID("page-new") - let oldRootID = DOMNode.ID(DOM.Node.ID("old-root")) - let newRootID = DOMNode.ID(DOM.Node.ID("new-root")) - let oldRouteChildID = DOMNode.ID(DOM.Node.ID("old-route-child")) - let retainedRequestID = Network.Request.ID("commit-retained-request") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: oldTargetID, - documentID: "old-root" - ) - let networkResults: WebInspectorFetchedResults = context.fetchedResults() - let domTreeController = try await context.treeController() - let domUpdates = DOMTreeUpdateRecorder(stream: domTreeController.updates) - defer { domUpdates.cancel() } - try await domUpdates.waitUntilStarted() - try await domUpdates.waitForUpdateCount(1) - let startupMessageCount = await backend.sentTargetMessages().count - - #expect(context.state == .attached) - #expect(context.rootNode?.id == oldRootID) - #expect(context.node(for: oldRootID) != nil) - await receiveTransportTargetEvent( - transport, - targetID: oldTargetID, - method: "Network.requestWillBeSent", - params: #"{"requestId":"commit-retained-request","request":{"url":"https://example.test/retained","method":"GET"},"type":"Fetch","timestamp":1}"# - ) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(retainedRequestID)] +private func withAttachedModelContext( + configuration: WebInspectorModelContext.Configuration, + document: DOM.Node = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document" + ), + _ operation: @MainActor (AttachedModelFixture) async throws -> Output +) async throws -> Output { + try await withDataKitTestRuntime { runtime in + let fixture = try await attachModelContext( + runtime: runtime, + configuration: configuration, + document: document + ) + let result: Result + do { + result = .success(try await operation(fixture)) + } catch { + result = .failure(error) + } + await enqueueShutdownReplies( + on: runtime.wire, + configuration: configuration + ) + await fixture.context.close() + return try result.get() } +} - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-new","type":"page","frameId":"new-main-frame","isProvisional":true}}}"# - ) - await transport.receiveRootMessage( - #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# +@MainActor +private func attachModelContext( + runtime: DataKitTestRuntime, + configuration: WebInspectorModelContext.Configuration, + document: DOM.Node = DOM.Node( + id: DOM.Node.ID("document"), + nodeType: 9, + nodeName: "#document" ) - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# +) async throws -> AttachedModelFixture { + let target = try await runtime.proxy.waitForCurrentPage() + await enqueueStartupReplies( + on: runtime.wire, + configuration: configuration, + document: document ) - await transport.receiveRootMessage( - #"{"method":"Page.frameNavigated","params":{"frame":{"id":"new-main-frame","loaderId":"loader-2","name":"Main","url":"https://example.test/next","securityOrigin":"https://example.test","mimeType":"text/html"}}}"# + let context = WebInspectorModelContext(configuration: configuration) + try await context.attach(to: runtime.proxy, isolation: MainActor.shared) + return AttachedModelFixture( + runtime: runtime, + target: target, + context: context, + configuration: configuration ) +} - try await replyTransportInspectorInitialization( - backend, - transport: transport, - targetID: newTargetID, - after: startupMessageCount, - timeout: .seconds(30) - ) - - let runtimeEnable = try await waitForTransportTargetMessage( - backend, - method: "Runtime.enable", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(runtimeEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" - ) - - let networkEnable = try await waitForTransportTargetMessage( - backend, - method: "Network.enable", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(networkEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" - ) - - let getDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(getDocument.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "new-root") - ) - - let consoleEnable = try await waitForTransportTargetMessageReplyingToInterleavedGetDocuments( - backend, - transport: transport, - targetID: newTargetID, - method: "Console.enable", - after: startupMessageCount, - documentNodeID: "new-root", - repliedGetDocumentMessageIDs: [try transportMessageID(getDocument.message)], - timeout: .seconds(30) - ) - #expect(consoleEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" - ) - - try await waitUntil { context.rootNode?.id == newRootID } - try await waitUntil { - domUpdates.updates.contains { update in - guard case let .snapshot(snapshot, .pageChanged) = update else { - return false - } - return snapshot.rootNodeID == newRootID - } - } - let resetUpdateIndex = try #require(domUpdates.updates.firstIndex { update in - guard case let .snapshot(snapshot, .reset) = update else { - return false - } - return snapshot.rootNodeID == nil - }) - let pageChangedUpdateIndex = try #require(domUpdates.updates.firstIndex { update in - guard case let .snapshot(snapshot, .pageChanged) = update else { - return false - } - return snapshot.rootNodeID == newRootID - }) - #expect(resetUpdateIndex < pageChangedUpdateIndex) - #expect(context.state == .attached) - #expect(context.node(for: oldRootID) == nil) - #expect(context.node(for: newRootID) != nil) - #expect(networkResults.items.map(\.id) == [NetworkRequest.ID(retainedRequestID)]) - - let postCommitRequestID = Network.Request.ID("commit-post-request") - await receiveTransportTargetEvent( - transport, - targetID: newTargetID, - method: "Network.requestWillBeSent", - params: #"{"requestId":"commit-post-request","request":{"url":"https://example.test/after-commit","method":"GET"},"type":"Fetch","timestamp":2}"# - ) - try await waitUntil { - Set(networkResults.items.map(\.id)) == [ - NetworkRequest.ID(retainedRequestID), - NetworkRequest.ID(postCommitRequestID), - ] - } - - let sentMessages = await backend.sentTargetMessages() - let retargetMessages = Array(sentMessages.dropFirst(startupMessageCount)) - let staleRouteMethods = try retargetMessages - .filter { $0.targetIdentifier == oldTargetID } - .map { try transportTargetMessageMethod($0.message) } - #expect(staleRouteMethods.allSatisfy { $0 == "DOM.getDocument" }) - - let newTargetMessages = retargetMessages.filter { $0.targetIdentifier == newTargetID } - let newTargetMethods = try newTargetMessages.map { try transportTargetMessageMethod($0.message) } - let runtimeEnableIndex = try #require(newTargetMethods.firstIndex(of: "Runtime.enable")) - let preRuntimeMethods = Array(newTargetMethods[.. = context.fetchedResults() - let startupMessageCount = await backend.sentTargetMessages().count - - #expect(context.state == .attached) - #expect(context.rootNode?.id == DOMNode.ID(DOM.Node.ID("destroyed-root"))) - await receiveTransportTargetEvent( - transport, - targetID: oldTargetID, - method: "Network.requestWillBeSent", - params: #"{"requestId":"destroy-retained-request","request":{"url":"https://example.test/retained","method":"GET"},"type":"Fetch","timestamp":1}"# - ) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(retainedRequestID)] - } - - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# - ) - #expect(context.state == .attached) - #expect(context.rootNode?.id == DOMNode.ID(DOM.Node.ID("destroyed-root"))) - #expect(networkResults.items.map(\.id) == [NetworkRequest.ID(retainedRequestID)]) - await installTransportPageTarget(in: transport, targetID: newTargetID) - - try await replyTransportInspectorInitialization( - backend, - transport: transport, - targetID: newTargetID, - after: startupMessageCount, - timeout: .seconds(30) - ) - - let runtimeEnable = try await waitForTransportTargetMessage( - backend, - method: "Runtime.enable", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(runtimeEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" - ) - - let networkEnable = try await waitForTransportTargetMessage( - backend, - method: "Network.enable", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(networkEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" - ) - - let getDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(getDocument.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "reattached-root") - ) - - let consoleEnable = try await waitForTransportTargetMessage( - backend, - method: "Console.enable", - after: startupMessageCount, - timeout: .seconds(30) - ) - #expect(consoleEnable.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" - ) - - try await waitUntil { - context.rootNode?.id == DOMNode.ID(DOM.Node.ID("reattached-root")) - } - #expect(context.state == .attached) - #expect(context.rootNode?.id == DOMNode.ID(DOM.Node.ID("reattached-root"))) - #expect(networkResults.items.map(\.id) == [NetworkRequest.ID(retainedRequestID)]) - - let pickerMessageCount = await backend.sentTargetMessages().count - let pickerTask = Task { @MainActor in - try await context.setElementPickerEnabled(true) - } - let inspectMode = try await waitForTransportTargetMessage( - backend, - method: "DOM.setInspectModeEnabled", - after: pickerMessageCount - ) - #expect(inspectMode.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: inspectMode.targetIdentifier, - messageID: try transportMessageID(inspectMode.message), - result: "{}" - ) - try await pickerTask.value - #expect(context.isElementPickerEnabled) -} - -@MainActor -@Test -func startBeginsFreshNetworkAttachmentEpoch() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let networkResults: WebInspectorFetchedResults = context.fetchedResults() - let staleRequestID = Network.Request.ID("stale-before-restart") - - await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.backend) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] - } - - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("fresh-after-restart"), nodeType: 9, nodeName: "#document") - ) - - context.start() - - try await waitUntil { networkResults.items.isEmpty } - try await waitUntil { - context.rootNode?.id == DOMNode.ID(DOM.Node.ID("fresh-after-restart")) - && context.state == .attached - } - #expect(networkResults.items.isEmpty) -} - -@MainActor -@Test -func sharedContainerContextsReenableDomainsOnCommittedPageTarget() async throws { - let oldTargetID = ProtocolTarget.ID("page-old") - let newTargetID = ProtocolTarget.ID("page-new") - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installTransportPageTarget(in: transport, targetID: oldTargetID) - let proxy = try await WebInspectorProxy(transport: transport) - let container = WebInspectorContainer(proxy: proxy) - - let autoReplier = startAutoReplyingTransportTargetMessages( - backend: backend, - transport: transport - ) { targetID in - targetID == newTargetID ? "new-shared-root" : "old-shared-root" - } - defer { autoReplier.cancel() } - - let contextA = container.mainContext - let contextB = WebInspectorContext(container, isolation: MainActor.shared) - contextB.start() - try await waitUntil(timeout: .seconds(5)) { - contextA.state == .attached && contextB.state == .attached - } - let preSwapMessageCount = await backend.sentTargetMessages().count - - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-new","type":"page","frameId":"new-main-frame","isProvisional":true}}}"# - ) - await transport.receiveRootMessage( - #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# - ) - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# - ) - - let newRootID = DOMNode.ID(DOM.Node.ID("new-shared-root")) - try await waitUntil(timeout: .seconds(5)) { - contextA.state == .attached && contextB.state == .attached - && contextA.rootNode?.id == newRootID - && contextB.rootNode?.id == newRootID - } - - let postSwapMessages = Array((await backend.sentTargetMessages()).dropFirst(preSwapMessageCount)) - for method in ["Inspector.enable", "Runtime.enable", "Network.enable", "Console.enable"] { - let sends = try postSwapMessages.filter { - try $0.targetIdentifier == newTargetID && transportTargetMessageMethod($0.message) == method - } - #expect(sends.count == 1, "expected exactly one \(method) on the committed page target") - } -} - -@MainActor -@Test -func currentPageDestroyWithoutReplacementRegressesToAttachingAndRecovers() async throws { - let doomedTargetID = ProtocolTarget.ID("page-doomed") - let rebornTargetID = ProtocolTarget.ID("page-reborn") - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installTransportPageTarget(in: transport, targetID: doomedTargetID) - let proxy = try await WebInspectorProxy( - transport: transport, - configuration: .init(responseTimeout: .milliseconds(750), bootstrapTimeout: .milliseconds(100)) - ) - let container = WebInspectorContainer(proxy: proxy) - - let autoReplier = startAutoReplyingTransportTargetMessages( - backend: backend, - transport: transport - ) { targetID in - targetID == rebornTargetID ? "reborn-root" : "doomed-root" - } - defer { autoReplier.cancel() } - - let context = container.mainContext - try await waitUntil(timeout: .seconds(5)) { context.state == .attached } - #expect(context.rootNode != nil) - - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-doomed"}}"# - ) - - try await waitUntil(timeout: .seconds(5)) { - context.state == .attaching && context.rootNode == nil - } - - await installTransportPageTarget(in: transport, targetID: rebornTargetID, frameID: "reborn-frame") - - let rebornRootID = DOMNode.ID(DOM.Node.ID("reborn-root")) - try await waitUntil(timeout: .seconds(5)) { - context.state == .attached && context.rootNode?.id == rebornRootID - } -} - -@MainActor -@Test -func startCancelsInFlightCurrentPageRetargetBeforeRestarting() async throws { - let targetID = ProtocolTarget.ID("page-restart") - let (_, _, context) = try await startTransportBackedContext( - targetID: targetID, - documentID: "restart-old-root" - ) - let cancellationProbe = CancellationProbe() - context.installCurrentPageRetargetTaskForTesting(Task { - cancellationProbe.markStarted() - await withTaskCancellationHandler { - while Task.isCancelled == false { - try? await Task.sleep(for: .milliseconds(10)) - } - cancellationProbe.markCancelled() - } onCancel: { - cancellationProbe.markCancelled() - } - }) - try await waitUntil(timeout: .seconds(5)) { - cancellationProbe.started() - } - - context.start() - try await waitUntil(timeout: .seconds(5)) { - cancellationProbe.cancelled() - } -} - -@MainActor -@Test -func domUndoRedoCommandsFailAfterCurrentPageRetarget() async throws { - let oldTargetID = ProtocolTarget.ID("page-undo-old") - let newTargetID = ProtocolTarget.ID("page-undo-new") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: oldTargetID, - documentID: "undo-old-root" - ) - let undoCommands = try context.domUndoRedoCommands() - let startupMessageCount = await backend.sentTargetMessages().count - - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-undo-new","type":"page","frameId":"main-frame","isProvisional":true}}}"# - ) - await transport.receiveRootMessage( - #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-undo-old","newTargetId":"page-undo-new"}}"# - ) - - try await replyTransportInspectorInitialization( - backend, - transport: transport, - targetID: newTargetID, - after: startupMessageCount - ) - - let runtimeEnable = try await waitForTransportTargetMessage( - backend, - method: "Runtime.enable", - after: startupMessageCount - ) - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" - ) - - let networkEnable = try await waitForTransportTargetMessage( - backend, - method: "Network.enable", - after: startupMessageCount - ) - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" - ) - - let getDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: startupMessageCount - ) - #expect(getDocument.targetIdentifier == newTargetID) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "undo-new-root") - ) - - let consoleEnable = try await waitForTransportTargetMessage( - backend, - method: "Console.enable", - after: startupMessageCount - ) - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" - ) - - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("undo-new-root")) } - await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { - try await undoCommands.undo() - } - - let sentMethods = try await backend.sentTargetMessages().map { message in - try transportTargetMessageMethod(message.message) - } - #expect(!sentMethods.contains("DOM.undo")) -} - -@MainActor -@Test -func mainFrameNavigatedReloadsDOMAndClearsRuntimeContexts() async throws { - let targetID = ProtocolTarget.ID("page-main") - let navigatedRootID = DOMNode.ID(DOM.Node.ID("navigated-root")) - let navigatedRequestID = Network.Request.ID("navigated-request") - let (backend, transport, context) = try await startTransportBackedContext( - targetID: targetID, - documentID: "initial-root" - ) - let networkResults: WebInspectorFetchedResults = context.fetchedResults() - let undoCommands = try context.domUndoRedoCommands() - let startupMessageCount = await backend.sentTargetMessages().count - - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "Runtime.executionContextCreated", - params: #"{"context":{"id":21,"name":"Main","frameId":"main-frame","type":"normal"}}"# - ) - try await waitUntil { context.executionContexts.map(\.id) == [RuntimeContext.ID(Runtime.ExecutionContext.ID("21"))] } - - await transport.receiveRootMessage( - #"{"method":"Page.frameNavigated","params":{"frame":{"id":"main-frame","loaderId":"loader-2","name":"Main","url":"https://example.test/next","securityOrigin":"https://example.test","mimeType":"text/html"}}}"# - ) - - let getDocument = try await waitForTransportTargetMessage( - backend, - method: "DOM.getDocument", - after: startupMessageCount - ) - #expect(getDocument.targetIdentifier == targetID) - #expect(context.executionContexts.isEmpty) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: "navigated-root") - ) - - try await waitUntil { context.rootNode?.id == navigatedRootID } - #expect(context.executionContexts.isEmpty) - await receiveTransportTargetEvent( - transport, - targetID: targetID, - method: "Network.requestWillBeSent", - params: #"{"requestId":"navigated-request","request":{"url":"https://example.test/after-frame-navigation","method":"GET"},"type":"Document","timestamp":3}"# - ) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(navigatedRequestID)] - } - await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { - try await undoCommands.undo() - } - - let sentMethods = try await backend.sentTargetMessages().map { message in - try transportTargetMessageMethod(message.message) - } - #expect(!sentMethods.contains("DOM.undo")) -} - -@MainActor -@Test -func restartClearsRuntimeContextsBeforeEnableReplay() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let staleID = Runtime.ExecutionContext.ID("stale") - let replayID = Runtime.ExecutionContext.ID("replayed") - let enableGate = WebInspectorTestGate() - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext(id: staleID, name: "Stale", kind: .normal)), - target: target - ) - try await waitUntil { - context.executionContexts.first?.id == RuntimeContext.ID(staleID) - } - - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: enableGate) - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) - - context.start() - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands + shutdownCommands + [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - ] - } - #expect(context.executionContexts.isEmpty) - #expect(context.selectedContext == nil) - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext(id: replayID, name: "Replayed", kind: .normal)), - target: target - ) - try await waitUntil { - context.executionContexts.map(\.id) == [RuntimeContext.ID(replayID)] - } - - await enableGate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } - #expect(context.selectedContext?.id == RuntimeContext.ID(replayID)) -} - -@MainActor -@Test -func restartClearsConsoleMessagesBeforeConsoleReplay() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let enableGate = WebInspectorTestGate() - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "old" - )), - target: target - ) - try await waitUntil { results.items.map(\.text) == ["old"] } - - await runtime.backend.hold(domain: "Console", method: "enable", gate: enableGate) - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("restarted-document"), nodeType: 9, nodeName: "#document") - ) - - context.start() - try await waitUntil { - await runtime.backend.recordedCommands() == startupCommands + shutdownCommands + startupCommands - } - #expect(results.items.isEmpty) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "old" - )), - target: target - ) - try await waitUntil { results.items.map(\.text) == ["old"] } - - await enableGate.open() - try await waitUntil { context.rootNode?.id == DOMNode.ID(DOM.Node.ID("restarted-document")) } - #expect(results.items.count == 1) -} - -@MainActor -@Test -func documentUpdatedReloadsRootDocument() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - await runtime.backend.enqueue((), for: "DOM", method: "setAttributeValue") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - try await context.dom.setAttribute("data-before-reset", value: "1", on: document.id) - let undoCommands = try context.domUndoRedoCommands() - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - let replacementID = DOM.Node.ID("replacement-document") - - await runtime.backend.enqueue( - DOM.Node(id: replacementID, nodeType: 9, nodeName: "#document"), - for: "DOM", - method: "getDocument" - ) - - await runtime.backend.emit(.documentUpdated, target: target) - - try await waitUntil { - context.rootNode?.id == DOMNode.ID(replacementID) - } - try await waitUntil { - recorder.updates.contains { update in - guard case let .snapshot(snapshot, .documentUpdated) = update else { - return false - } - return snapshot.rootNodeID == DOMNode.ID(replacementID) - } - } - let resetUpdateIndex = try #require(recorder.updates.firstIndex { update in - guard case let .snapshot(snapshot, .reset) = update else { - return false - } - return snapshot.rootNodeID == nil - }) - let documentUpdatedIndex = try #require(recorder.updates.firstIndex { update in - guard case let .snapshot(snapshot, .documentUpdated) = update else { - return false - } - return snapshot.rootNodeID == DOMNode.ID(replacementID) - }) - #expect(resetUpdateIndex < documentUpdatedIndex) - await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { - try await undoCommands.undo() - } - await runtime.backend.enqueue((), for: "DOM", method: "undo") - await #expect(throws: WebInspectorProxyError.disconnected("DOM undo/redo target is no longer current.")) { - try await context.editHistory.undo() - } - - let commands = await runtime.backend.recordedCommands() - #expect(!commands.contains { $0.domain == "DOM" && $0.method == "undo" }) -} - -@MainActor -@Test -func childInsertIntoUnrequestedParentDoesNotMarkChildrenLoaded() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let insertedID = DOM.Node.ID("inserted-child") - - await runtime.backend.emit( - .childNodeInserted( - parent: document.id.proxyID, - previous: nil, - node: DOM.Node( - id: insertedID, - nodeType: 1, - nodeName: "DIV", - localName: "div" - ) - ), - target: target - ) - - try await waitUntil { - document.childNodeCount == 1 - } - guard case let .unrequested(count) = document.children else { - Issue.record("Expected parent children to stay unrequested.") - return - } - #expect(count == 1) - #expect(context.node(for: DOMNode.ID(insertedID)) == nil) -} - -@MainActor -@Test -func domTreeControllerPublishesInitialSnapshotAndChildDeltas() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document", childNodeCount: 1) - ) - let document = try #require(context.rootNode) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - #expect(controller.snapshot.rootNodeID == document.id) - #expect(Set(controller.snapshot.nodesByID.keys) == Set([document.id])) - #expect(controller.snapshot.node(for: document.id)?.children == .unrequested(count: 1)) - guard case let .snapshot(initialSnapshot, .initialDocument) = recorder.updates.first else { - Issue.record("Expected initial DOM tree snapshot.") - return - } - #expect(initialSnapshot.node(for: document.id)?.children == .unrequested(count: 1)) - - let childID = DOM.Node.ID("child") - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - - try await recorder.waitForUpdateCount(2) - guard case let .delta(childrenChanged) = recorder.updates.last else { - Issue.record("Expected DOM children replacement delta.") - return - } - #expect(childrenChanged == .childrenReplaced(parentID: document.id, childIDs: [DOMNode.ID(childID)])) - #expect(controller.snapshot.children(of: document.id) == [DOMNode.ID(childID)]) - #expect(controller.snapshot.parent(of: DOMNode.ID(childID)) == document.id) -} - -@MainActor -@Test -func domTreeControllerPrunesRetainedChildDescendantsOnReplacement() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document", childNodeCount: 1) - ) - let document = try #require(context.rootNode) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - let childID = DOM.Node.ID("child") - let removedSpanID = DOM.Node.ID("removed-span") - let removedEmID = DOM.Node.ID("removed-em") - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - children: [ - DOM.Node(id: removedSpanID, nodeType: 1, nodeName: "SPAN", localName: "span"), - DOM.Node(id: removedEmID, nodeType: 1, nodeName: "EM", localName: "em"), - ] - ) - ]), - target: target - ) - - try await recorder.waitForUpdateCount(2) - #expect(controller.snapshot.node(for: DOMNode.ID(removedSpanID)) != nil) - #expect(controller.snapshot.node(for: DOMNode.ID(removedEmID)) != nil) - let child = try #require(context.node(for: DOMNode.ID(childID))) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 0) - ]), - target: target - ) - - try await recorder.waitForUpdateCount(3) - try await waitUntil { - context.node(for: DOMNode.ID(removedSpanID)) == nil - && context.node(for: DOMNode.ID(removedEmID)) == nil - } - #expect(context.node(for: DOMNode.ID(childID)) === child) - #expect(controller.snapshot.node(for: DOMNode.ID(childID)) != nil) - #expect(controller.snapshot.node(for: DOMNode.ID(removedSpanID)) == nil) - #expect(controller.snapshot.node(for: DOMNode.ID(removedEmID)) == nil) - #expect(controller.snapshot.parent(of: DOMNode.ID(removedEmID)) == nil) -} - -@MainActor -@Test -func domTreeControllerPublishesAssociatedSubtreeDeltas() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document", childNodeCount: 1) - ) - let document = try #require(context.rootNode) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - let iframeID = DOM.Node.ID("iframe") - let frameDocumentID = DOM.Node.ID("frame-document") - let frameBodyID = DOM.Node.ID("frame-body") - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: iframeID, - nodeType: 1, - nodeName: "IFRAME", - localName: "iframe", - frameID: FrameID("child-frame"), - childNodeCount: 0, - contentDocument: DOM.Node( - id: frameDocumentID, - nodeType: 9, - nodeName: "#document", - children: [ - DOM.Node(id: frameBodyID, nodeType: 1, nodeName: "BODY", localName: "body") - ] - ) - ) - ]), - target: target - ) - - try await recorder.waitForUpdateCount(2) - guard case let .delta(childrenChanged) = recorder.updates.last else { - Issue.record("Expected associated subtree replacement delta.") - return - } - #expect(childrenChanged == .childrenReplaced(parentID: document.id, childIDs: [DOMNode.ID(iframeID)])) - #expect(controller.snapshot.visibleChildren(of: DOMNode.ID(iframeID)).nodeIDs == [DOMNode.ID(frameDocumentID)]) - #expect(controller.snapshot.parent(of: DOMNode.ID(frameDocumentID)) == DOMNode.ID(iframeID)) - #expect(controller.snapshot.parent(of: DOMNode.ID(frameBodyID)) == DOMNode.ID(frameDocumentID)) -} - -@MainActor -@Test -func domTreeControllerAppliesDynamicShadowAndPseudoElementDeltas() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node( - id: DOM.Node.ID("document"), - nodeType: 9, - nodeName: "#document", - childNodeCount: 1 - ) - ) - let document = try #require(context.rootNode) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - let hostID = DOM.Node.ID("shadow-host") - let shadowRootID = DOM.Node.ID("dynamic-shadow-root") - let shadowChildID = DOM.Node.ID("dynamic-shadow-child") - let beforePseudoID = DOM.Node.ID("dynamic-before-pseudo") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: hostID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - try await recorder.waitForUpdateCount(2) - let host = try #require(context.node(for: DOMNode.ID(hostID))) - - await runtime.backend.emit( - .shadowRootPushed( - host: hostID, - root: DOM.Node( - id: shadowRootID, - nodeType: 11, - nodeName: "#shadow-root", - children: [ - DOM.Node( - id: shadowChildID, - nodeType: 1, - nodeName: "SPAN", - localName: "span" - ) - ], - shadowRootType: .open - ) - ), - target: target - ) - - try await recorder.waitForUpdateCount(3) - #expect(host.shadowRoots.map(\.id) == [DOMNode.ID(shadowRootID)]) - #expect( - controller.snapshot.visibleChildren(of: DOMNode.ID(hostID)).nodeIDs == [DOMNode.ID(shadowRootID)] - ) - #expect(controller.snapshot.parent(of: DOMNode.ID(shadowRootID)) == DOMNode.ID(hostID)) - #expect(controller.snapshot.parent(of: DOMNode.ID(shadowChildID)) == DOMNode.ID(shadowRootID)) - #expect(recorder.updates.last == .delta(.childrenReplaced( - parentID: DOMNode.ID(hostID), - childIDs: [DOMNode.ID(shadowRootID)] - ))) - - await runtime.backend.emit( - .pseudoElementAdded( - parent: hostID, - element: DOM.Node( - id: beforePseudoID, - nodeType: 1, - nodeName: "::before", - pseudoType: .before - ) - ), - target: target - ) - - try await recorder.waitForUpdateCount(4) - #expect(host.beforePseudoElement?.id == DOMNode.ID(beforePseudoID)) - #expect(controller.snapshot.visibleChildren(of: DOMNode.ID(hostID)).nodeIDs == [ - DOMNode.ID(beforePseudoID), - DOMNode.ID(shadowRootID), - ]) - #expect(controller.snapshot.parent(of: DOMNode.ID(beforePseudoID)) == DOMNode.ID(hostID)) - - await runtime.backend.emit( - .pseudoElementRemoved(parent: hostID, element: beforePseudoID), - target: target - ) - - try await recorder.waitForUpdateCount(5) - #expect(host.beforePseudoElement == nil) - #expect(context.node(for: DOMNode.ID(beforePseudoID)) == nil) - #expect( - controller.snapshot.visibleChildren(of: DOMNode.ID(hostID)).nodeIDs == [DOMNode.ID(shadowRootID)] - ) - - await runtime.backend.emit( - .shadowRootPopped(host: hostID, root: shadowRootID), - target: target - ) - - try await recorder.waitForUpdateCount(6) - #expect(host.shadowRoots.isEmpty) - #expect(context.node(for: DOMNode.ID(shadowRootID)) == nil) - #expect(context.node(for: DOMNode.ID(shadowChildID)) == nil) - #expect(controller.snapshot.visibleChildren(of: DOMNode.ID(hostID)).nodeIDs == []) -} - -@MainActor -@Test -func domTreeControllerPublishesOnlyDeltasForSameDocumentMutations() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document", childNodeCount: 1) - ) - let document = try #require(context.rootNode) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - let elementID = DOM.Node.ID("element") - let textID = DOM.Node.ID("text") - let insertedID = DOM.Node.ID("inserted") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 1) - ]), - target: target - ) - try await recorder.waitForUpdateCount(2) - - await runtime.backend.emit( - .attributeModified(elementID, name: "class", value: "selected"), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(elementID))?.attributes["class"] == "selected" } - try await recorder.waitForUpdateCount(3) - - await runtime.backend.emit( - .childNodeCountUpdated(elementID, count: 1), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(elementID))?.childNodeCount == 1 } - try await recorder.waitForUpdateCount(4) - - await runtime.backend.emit( - .setChildNodes(parent: elementID, nodes: [ - DOM.Node(id: textID, nodeType: 3, nodeName: "#text", nodeValue: "old") - ]), - target: target - ) - try await recorder.waitForUpdateCount(5) - - await runtime.backend.emit( - .characterDataModified(textID, value: "new"), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(textID))?.nodeValue == "new" } - try await recorder.waitForUpdateCount(6) - - await runtime.backend.emit( - .childNodeInserted( - parent: document.id.proxyID, - previous: elementID, - node: DOM.Node(id: insertedID, nodeType: 1, nodeName: "SPAN", localName: "span") - ), - target: target - ) - try await recorder.waitForUpdateCount(7) - - await runtime.backend.emit( - .childNodeRemoved(parent: document.id.proxyID, node: insertedID), - target: target - ) - try await recorder.waitForUpdateCount(8) - - let mutationUpdates = Array(recorder.updates.dropFirst()) - #expect(mutationUpdates.allSatisfy { update in - guard case .delta = update else { - return false - } - return true - }) - #expect(mutationUpdates == [ - .delta(.childrenReplaced(parentID: document.id, childIDs: [DOMNode.ID(elementID)])), - .delta(.nodeChanged(nodeID: DOMNode.ID(elementID))), - .delta(.childCountChanged(nodeID: DOMNode.ID(elementID))), - .delta(.childrenReplaced(parentID: DOMNode.ID(elementID), childIDs: [DOMNode.ID(textID)])), - .delta(.nodeChanged(nodeID: DOMNode.ID(textID))), - .delta(.childInserted( - parentID: document.id, - nodeID: DOMNode.ID(insertedID), - previousSiblingID: DOMNode.ID(elementID) - )), - .delta(.childRemoved(parentID: document.id, nodeID: DOMNode.ID(insertedID))), - ]) -} - -@MainActor -@Test -func domTreeControllerSnapshotIncludesRecursiveDOMAssociations() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let documentID = DOM.Node.ID("document") - let iframeID = DOM.Node.ID("iframe") - let frameDocumentID = DOM.Node.ID("frame-document") - let frameBodyID = DOM.Node.ID("frame-body") - let templateHostID = DOM.Node.ID("template-host") - let templateContentID = DOM.Node.ID("template-content") - let shadowHostID = DOM.Node.ID("shadow-host") - let beforePseudoID = DOM.Node.ID("before-pseudo") - let shadowRootID = DOM.Node.ID("shadow-root") - let shadowSpanID = DOM.Node.ID("shadow-span") - let afterPseudoID = DOM.Node.ID("after-pseudo") - let ignoredIframeChildID = DOM.Node.ID("ignored-iframe-child") - - let (_, context) = try await startContext( - runtime: runtime, - document: DOM.Node( - id: documentID, - nodeType: 9, - nodeName: "#document", - children: [ - DOM.Node( - id: iframeID, - nodeType: 1, - nodeName: "IFRAME", - localName: "iframe", - frameID: FrameID("child-frame"), - documentURL: "https://example.test/frame", - baseURL: "https://example.test/", - attributes: ["data-second": "2", "src": "/frame"], - attributeList: [ - DOM.Attribute(name: "src", value: "/frame"), - DOM.Attribute(name: "data-second", value: "2"), - ], - children: [ - DOM.Node(id: ignoredIframeChildID, nodeType: 1, nodeName: "SPAN", localName: "span") - ], - contentDocument: DOM.Node( - id: frameDocumentID, - nodeType: 9, - nodeName: "#document", - children: [ - DOM.Node(id: frameBodyID, nodeType: 1, nodeName: "BODY", localName: "body") - ] - ) - ), - DOM.Node( - id: templateHostID, - nodeType: 1, - nodeName: "TEMPLATE", - localName: "template", - templateContent: DOM.Node( - id: templateContentID, - nodeType: 11, - nodeName: "#document-fragment" - ) - ), - DOM.Node( - id: shadowHostID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - shadowRoots: [ - DOM.Node( - id: shadowRootID, - nodeType: 11, - nodeName: "#shadow-root", - children: [ - DOM.Node(id: shadowSpanID, nodeType: 1, nodeName: "SPAN", localName: "span") - ], - shadowRootType: .open - ) - ], - beforePseudoElement: DOM.Node( - id: beforePseudoID, - nodeType: 1, - nodeName: "::before", - pseudoType: .before - ), - afterPseudoElement: DOM.Node( - id: afterPseudoID, - nodeType: 1, - nodeName: "::after", - pseudoType: .after - ) - ), - ] - ) - ) - - let controller = try await context.treeController() - let snapshot = controller.snapshot - let document = try #require(context.rootNode) - let iframe = try #require(snapshot.node(for: DOMNode.ID(iframeID))) - let templateContent = try #require(snapshot.node(for: DOMNode.ID(templateContentID))) - let shadowRoot = try #require(snapshot.node(for: DOMNode.ID(shadowRootID))) - - #expect(snapshot.displayRootIDs() == [DOMNode.ID(iframeID), DOMNode.ID(templateHostID), DOMNode.ID(shadowHostID)]) - #expect(snapshot.visibleChildren(of: DOMNode.ID(iframeID)).nodeIDs == [DOMNode.ID(frameDocumentID)]) - #expect(snapshot.visibleChildren(of: DOMNode.ID(templateHostID)).nodeIDs == [DOMNode.ID(templateContentID)]) - #expect(snapshot.visibleChildren(of: DOMNode.ID(shadowHostID)).nodeIDs == [ - DOMNode.ID(beforePseudoID), - DOMNode.ID(shadowRootID), - DOMNode.ID(afterPseudoID), - ]) - #expect(snapshot.visibleChildren(of: DOMNode.ID(shadowRootID)).nodeIDs == [DOMNode.ID(shadowSpanID)]) - #expect(snapshot.children(of: DOMNode.ID(iframeID)) == [DOMNode.ID(ignoredIframeChildID)]) - #expect(snapshot.parent(of: DOMNode.ID(frameDocumentID)) == DOMNode.ID(iframeID)) - #expect(snapshot.parent(of: DOMNode.ID(templateContentID)) == DOMNode.ID(templateHostID)) - #expect(snapshot.parent(of: DOMNode.ID(beforePseudoID)) == DOMNode.ID(shadowHostID)) - #expect(snapshot.isTemplateContent(DOMNode.ID(templateContentID))) - #expect(iframe.kind == DOMNode.Kind.element) - #expect(iframe.frameID == FrameID("child-frame")) - #expect(iframe.documentURL == "https://example.test/frame") - #expect(iframe.baseURL == "https://example.test/") - #expect(iframe.attributes["src"] == "/frame") - #expect(iframe.attributeList.map(\.name) == ["src", "data-second"]) - #expect(iframe.contentDocumentID == DOMNode.ID(frameDocumentID)) - #expect(templateContent.kind == DOMNode.Kind.documentFragment) - #expect(shadowRoot.shadowRootType == DOM.ShadowRootType.open) - #expect(document.contentDocument == nil) -} - -@MainActor -@Test -func domTreeControllerPublishesSelectionDeltasWithoutOwningExpansion() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") - ) - let document = try #require(context.rootNode) - let parentID = DOM.Node.ID("parent") - let childID = DOM.Node.ID("child") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: parentID, - nodeType: 1, - nodeName: "SECTION", - localName: "section", - children: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "SPAN", localName: "span") - ] - ) - ]), - target: target - ) - - try await waitUntil { context.node(for: DOMNode.ID(childID)) != nil } - let parent = try #require(context.node(for: DOMNode.ID(parentID))) - let child = try #require(context.node(for: DOMNode.ID(childID))) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - let revealRecorder = DOMTreeRevealRequestRecorder(stream: controller.revealRequests) - defer { recorder.cancel() } - defer { revealRecorder.cancel() } - try await recorder.waitUntilStarted() - try await revealRecorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - #expect(controller.snapshot.children(of: document.id) == [parent.id]) - #expect(controller.snapshot.children(of: parent.id) == [child.id]) - #expect(controller.snapshot.ancestorNodeIDs(of: child.id) == [parent.id, document.id]) - - await enqueueCSSStyleReplies(on: runtime.backend) - try context.dom.select(child.id, reveal: .selectOnly) - - try await recorder.waitForUpdateCount(2) - try await revealRecorder.waitForRequestCount(1) - guard case let .delta(selection) = recorder.updates.last else { - Issue.record("Expected DOM selection delta.") - return - } - #expect(selection == .selectionChanged(nodeID: child.id)) - #expect(controller.snapshot.selectedNodeID == child.id) - #expect(revealRecorder.requests.last == DOMTreeRevealRequest( - nodeID: child.id, - ancestorNodeIDs: [parent.id, document.id], - shouldSelect: true, - shouldScroll: false - )) - - context.select(nil) - - try await recorder.waitForUpdateCount(3) - guard case let .delta(selectionCleared) = recorder.updates.last else { - Issue.record("Expected DOM selection clear delta.") - return - } - #expect(selectionCleared == .selectionChanged(nodeID: nil)) - #expect(controller.snapshot.selectedNodeID == nil) - - try context.dom.select(document.id, reveal: .none) - try await recorder.waitForUpdateCount(4) - #expect(revealRecorder.requests.count == 1) -} - -@MainActor -@Test -func setChildNodesReplacementPublishesSelectionClearingForRemovedDescendant() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext( - runtime: runtime, - document: DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") - ) - let document = try #require(context.rootNode) - let parentID = DOM.Node.ID("parent") - let selectedID = DOM.Node.ID("selected-text") - let replacementID = DOM.Node.ID("replacement-text") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: parentID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - children: [ - DOM.Node(id: selectedID, nodeType: 3, nodeName: "#text", nodeValue: "selected") - ] - ) - ]), - target: target - ) - try await waitUntil { context.node(for: DOMNode.ID(selectedID)) != nil } - - let selected = try #require(context.node(for: DOMNode.ID(selectedID))) - let controller = try await context.treeController() - let recorder = DOMTreeUpdateRecorder(stream: controller.updates) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - try await recorder.waitForUpdateCount(1) - - context.select(selected) - try await recorder.waitForUpdateCount(2) - #expect(controller.snapshot.selectedNodeID == selected.id) - - await runtime.backend.emit( - .setChildNodes(parent: parentID, nodes: [ - DOM.Node(id: replacementID, nodeType: 3, nodeName: "#text", nodeValue: "replacement") - ]), - target: target - ) - - try await waitUntil { - context.selectedNode == nil - && context.node(for: DOMNode.ID(selectedID)) == nil - && context.node(for: DOMNode.ID(replacementID)) != nil - } - try await waitUntil { - recorder.updates.contains(.delta(.selectionChanged(nodeID: nil))) - } - #expect(controller.snapshot.selectedNodeID == nil) -} - - -@MainActor -@Test -func fetchedResultsControllerPublishesNetworkTopologyTransactionsOnly() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let requestID = Network.Request.ID("controller-request") - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/first", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - - try await recorder.waitForTransactionCount(1) - let inserted = try #require(recorder.transactions.last) - let modelID = NetworkRequest.ID(requestID) - let firstIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - #expect(inserted.itemChanges == [.insert(itemID: modelID, indexPath: firstIndexPath)]) - #expect(inserted.oldSnapshot.itemIDs == []) - #expect(inserted.newSnapshot.itemIDs == [modelID]) - #expect(controller.snapshot.itemIDs == [modelID]) - let request = try #require(results.items.first) - - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response(status: 200, statusText: "OK", mimeType: "text/plain"), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - - try await waitUntil { request.status == 200 } - #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) - - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), - target: target - ) - - try await waitUntil { request.decodedDataLength == 7 && request.encodedDataLength == 3 } - #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) - - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), - target: target - ) - - try await waitUntil { request.state == .finished } - #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) - - await runtime.backend.emit( - .requestServedFromMemoryCache( - id: requestID, - response: Network.Response(url: "https://example.com/first", status: 200), - resourceType: nil, - timestamp: 5 - ), - target: target - ) - - try await waitUntil { request.finishedOrFailedTimestamp == 5 } - #expect(results.items.first === request) - #expect(recorder.transactions.count == 1) - - let failedRequestID = Network.Request.ID("controller-failed-request") - let failedModelID = NetworkRequest.ID(failedRequestID) - let failedIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 1) - await runtime.backend.emit( - .requestWillBeSent( - id: failedRequestID, - request: Network.Request(id: failedRequestID, url: "https://example.com/failed", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 6 - ), - target: target - ) - - try await recorder.waitForTransactionCount(2) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: failedModelID, indexPath: failedIndexPath)]) - let failedRequest = try #require(results.items.last) - - await runtime.backend.emit( - .loadingFailed(id: failedRequestID, errorText: "cancelled", canceled: true, timestamp: 7), - target: target - ) - - try await waitUntil { - if case .failed(errorText: "cancelled", canceled: true) = failedRequest.state { - return true - } - return false - } - #expect(results.items.last === failedRequest) - #expect(recorder.transactions.count == 2) - - let socketRequestID = Network.Request.ID("controller-socket-request") - let socketModelID = NetworkRequest.ID(socketRequestID) - let socketIndexPath = WebInspectorFetchedResultsIndexPath(section: 0, item: 2) - await runtime.backend.emit( - .webSocket(.created(id: socketRequestID, url: "wss://example.com/socket")), - target: target - ) - - try await recorder.waitForTransactionCount(3) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: socketModelID, indexPath: socketIndexPath)]) - let socketRequest = try #require(results.items.last) - - await runtime.backend.emit( - .webSocket(.handshakeRequest( - id: socketRequestID, - request: Network.Request( - id: socketRequestID, - url: "wss://example.com/socket", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - timestamp: 8 - )), - target: target - ) - - try await waitUntil { socketRequest.webSocket?.handshakeRequest?.headers["Upgrade"] == "websocket" } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) - - await runtime.backend.emit( - .webSocket(.handshakeResponse( - id: socketRequestID, - response: Network.Response(status: 101, statusText: "Switching Protocols"), - timestamp: 9 - )), - target: target - ) - - try await waitUntil { socketRequest.status == 101 } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) - - await runtime.backend.emit( - .webSocket(.frameSent( - id: socketRequestID, - frame: Network.WebSocketFrame(opcode: 1, mask: true, payloadData: "hello", payloadLength: 5), - timestamp: 10 - )), - target: target - ) - - try await waitUntil { socketRequest.webSocket?.frames.count == 1 } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) - - await runtime.backend.emit( - .webSocket(.frameReceived( - id: socketRequestID, - frame: Network.WebSocketFrame(opcode: 1, mask: false, payloadData: "world", payloadLength: 5), - timestamp: 11 - )), - target: target - ) - - try await waitUntil { socketRequest.webSocket?.frames.count == 2 } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) - - await runtime.backend.emit( - .webSocket(.error(id: socketRequestID, message: "decode failed", timestamp: 12)), - target: target - ) - - try await waitUntil { socketRequest.webSocket?.frames.count == 3 } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) - - await runtime.backend.emit( - .webSocket(.closed(id: socketRequestID, timestamp: 13)), - target: target - ) - - try await waitUntil { socketRequest.state == .finished && socketRequest.webSocket?.readyState == .closed } - #expect(results.items.last === socketRequest) - #expect(recorder.transactions.count == 3) -} - -@MainActor -@Test -func sectionedNetworkResultsPublishTopologyWhenSectionKeyChanges() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.mimeType) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let requestID = Network.Request.ID("sectioned-controller-request") - let modelID = NetworkRequest.ID(requestID) - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://media.example.com/clip.mp4", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - - try await recorder.waitForTransactionCount(1) - #expect(controller.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) - #expect(controller.snapshot.itemIDs == [modelID]) - - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - url: "https://media.example.com/clip.mp4", - status: 200, - mimeType: "video/mp4" - ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - - try await recorder.waitForTransactionCount(2) - let sectionChange = try #require(recorder.transactions.last) - #expect(sectionChange.isReset == false) - #expect(sectionChange.oldSnapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "")]) - #expect(sectionChange.newSnapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "video/mp4")]) - #expect(sectionChange.oldSnapshot.itemIDs == [modelID]) - #expect(sectionChange.newSnapshot.itemIDs == [modelID]) - #expect(sectionChange.sectionChanges == [ - .delete(sectionID: WebInspectorFetchSectionID(rawValue: ""), index: 0), - .insert(sectionID: WebInspectorFetchSectionID(rawValue: "video/mp4"), index: 0), - ]) - #expect(sectionChange.itemChanges == []) -} - -@MainActor -@Test -func sectionedNetworkResultsPublishItemMoveWhenSectionKeyChangesBetweenExistingSections() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.resourceCategory) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let imageID = Network.Request.ID("existing-image-section") - let movingID = Network.Request.ID("moving-xhr-to-image") - let remainingXHRID = Network.Request.ID("remaining-xhr-section") - await context.apply(.requestWillBeSent( - id: imageID, - request: Network.Request(id: imageID, url: "https://cdn.example.com/photo.png", method: "GET"), - resourceType: .image, - redirectResponse: nil, - timestamp: 1 - )) - await context.apply(.requestWillBeSent( - id: movingID, - request: Network.Request(id: movingID, url: "https://api.example.com/avatar", method: "GET"), - resourceType: .xhr, - redirectResponse: nil, - timestamp: 2 - )) - await context.apply(.requestWillBeSent( - id: remainingXHRID, - request: Network.Request(id: remainingXHRID, url: "https://api.example.com/data", method: "GET"), - resourceType: .xhr, - redirectResponse: nil, - timestamp: 3 - )) - try await recorder.waitForTransactionCount(3) - - let movingModelID = NetworkRequest.ID(movingID) - await context.apply(.responseReceived( - id: movingID, - response: Network.Response( - url: "https://api.example.com/avatar", - status: 200, - mimeType: "image/png" - ), - resourceType: .xhr, - timestamp: 4 - )) - - try await recorder.waitForTransactionCount(4) - #expect(controller.snapshot.sections.map(\.id) == [ - WebInspectorFetchSectionID(rawValue: "image"), - WebInspectorFetchSectionID(rawValue: "xhrFetch"), - ]) - #expect(controller.snapshot.sections.map(\.itemIDs) == [ - [NetworkRequest.ID(imageID), movingModelID], - [NetworkRequest.ID(remainingXHRID)], - ]) - #expect(recorder.transactions.last?.itemChanges == [ - .move( - itemID: movingModelID, - from: WebInspectorFetchedResultsIndexPath(section: 1, item: 0), - to: WebInspectorFetchedResultsIndexPath(section: 0, item: 1) - ), - ]) -} - -@MainActor -@Test -func networkFetchDescriptorAppliesPredicateSortAndLimit() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("graphql-old"), - request: Network.Request(id: Network.Request.ID("graphql-old"), url: "https://api.example.com/graphql?older", method: "POST"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("image"), - request: Network.Request(id: Network.Request.ID("image"), url: "https://static.example.com/photo.png", method: "GET"), - resourceType: .image, - redirectResponse: nil, - timestamp: 2 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("graphql-new"), - request: Network.Request(id: Network.Request.ID("graphql-new"), url: "https://api.example.com/graphql?newer", method: "POST"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 3 - )) - - let xhrFetch = NetworkRequest.ResourceCategory.xhrFetch - let search = "graphql" - let descriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { request in - request.resourceCategory == xhrFetch - && request.searchableText.localizedStandardContains(search) - }, - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .reverse)], - fetchLimit: 1 - ) - - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - - #expect(results.items.map(\.id) == [NetworkRequest.ID(Network.Request.ID("graphql-new"))]) -} - -@MainActor -@Test -func networkFetchDescriptorPlansURLAndMIMETypePredicates() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("api"), - request: Network.Request(id: Network.Request.ID("api"), url: "https://api.example.com/data.json", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("image"), - request: Network.Request(id: Network.Request.ID("image"), url: "https://static.example.com/photo", method: "GET"), - resourceType: .image, - redirectResponse: nil, - timestamp: 2 - )) - await context.apply(.responseReceived( - id: Network.Request.ID("image"), - response: Network.Response( - url: "https://static.example.com/photo", - status: 200, - mimeType: "image/png" - ), - resourceType: .image, - timestamp: 3 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("script"), - request: Network.Request(id: Network.Request.ID("script"), url: "https://cdn.example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 4 - )) - - let descriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { request in - request.url.localizedStandardContains("api") - || request.mimeType == "image/png" - }, - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .forward)] - ) - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - - #expect(results.items.map(\.id) == [ - NetworkRequest.ID(Network.Request.ID("api")), - NetworkRequest.ID(Network.Request.ID("image")), - ]) -} - -@MainActor -@Test -func clearNetworkRequestsResetsDescriptorBackedQueryState() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let descriptor = WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .forward)], - fetchLimit: 2 - ) - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - - for (index, name) in ["stale-a", "stale-b", "stale-c"].enumerated() { - await context.apply(.requestWillBeSent( - id: Network.Request.ID(name), - request: Network.Request(id: Network.Request.ID(name), url: "https://example.com/\(name)", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: Double(index + 1) - )) - } - #expect(results.items.map(\.id) == [ - NetworkRequest.ID(Network.Request.ID("stale-a")), - NetworkRequest.ID(Network.Request.ID("stale-b")), - ]) - - context.clearNetworkRequests() - #expect(results.items.isEmpty) - - let freshID = Network.Request.ID("fresh-after-clear") - await context.apply(.requestWillBeSent( - id: freshID, - request: Network.Request(id: freshID, url: "https://example.com/fresh", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 10 - )) - - #expect(results.items.map(\.id) == [NetworkRequest.ID(freshID)]) -} - -@MainActor -@Test -func startResetsDescriptorBackedNetworkQueryState() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let descriptor = WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .forward)], - fetchLimit: 1 - ) - let networkResults: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - let staleRequestID = Network.Request.ID("stale-query-before-restart") - - await emitFinishedRequest(id: staleRequestID, target: target, backend: runtime.backend) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(staleRequestID)] - } - - await enqueueDomainDisableReplies(on: runtime.backend) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: DOM.Node.ID("fresh-query-root"), nodeType: 9, nodeName: "#document") - ) - - context.start() - - try await waitUntil { - networkResults.items.isEmpty && context.state == .attached - } - - let freshRequestID = Network.Request.ID("fresh-query-after-restart") - await emitFinishedRequest(id: freshRequestID, target: target, backend: runtime.backend) - try await waitUntil { - networkResults.items.map(\.id) == [NetworkRequest.ID(freshRequestID)] - } -} - -@MainActor -@Test -func networkFetchDescriptorOrdersEqualTimestampsByNewestInsertionFirst() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let descriptor = WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .reverse)] - ) - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let firstID = Network.Request.ID("same-timestamp-first") - let secondID = Network.Request.ID("same-timestamp-second") - let firstModelID = NetworkRequest.ID(firstID) - let secondModelID = NetworkRequest.ID(secondID) - - await context.apply(.requestWillBeSent( - id: firstID, - request: Network.Request(id: firstID, url: "https://example.com/first", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - )) - try await recorder.waitForTransactionCount(1) - #expect(results.items.map(\.id) == [firstModelID]) - - await context.apply(.requestWillBeSent( - id: secondID, - request: Network.Request(id: secondID, url: "https://example.com/second", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - )) - - try await recorder.waitForTransactionCount(2) - #expect(results.items.map(\.id) == [secondModelID, firstModelID]) - #expect(recorder.transactions.last?.itemChanges == [ - .insert(itemID: secondModelID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), - ]) -} - -@MainActor -@Test -func networkFetchDescriptorPublishesPredicateEnterAndLeave() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let descriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { request in - (request.statusCode ?? 0) >= 400 - } - ) - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let requestID = Network.Request.ID("status-request") - let modelID = NetworkRequest.ID(requestID) - await context.apply(.requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://api.example.com/status", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - )) - #expect(results.items.isEmpty) - #expect(recorder.transactions.isEmpty) - - await context.apply(.responseReceived( - id: requestID, - response: Network.Response(url: "https://api.example.com/status", status: 500), - resourceType: .fetch, - timestamp: 2 - )) - - try await recorder.waitForTransactionCount(1) - #expect(results.items.map(\.id) == [modelID]) - #expect(recorder.transactions.last?.itemChanges == [ - .insert(itemID: modelID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), - ]) - - await context.apply(.responseReceived( - id: requestID, - response: Network.Response(url: "https://api.example.com/status", status: 200), - resourceType: .fetch, - timestamp: 3 - )) - - try await recorder.waitForTransactionCount(2) - #expect(results.items.isEmpty) - #expect(recorder.transactions.last?.itemChanges == [ - .delete(itemID: modelID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), - ]) - #expect(context.registeredRequest(for: modelID) != nil) -} - -@MainActor -@Test -func networkFetchDescriptorSupportsResourceCategorySets() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - - await context.apply(.requestWillBeSent( - id: Network.Request.ID("pending-avatar"), - request: Network.Request(id: Network.Request.ID("pending-avatar"), url: "https://api.example.com/avatar.png", method: "GET"), - resourceType: .xhr, - redirectResponse: nil, - timestamp: 1 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("script"), - request: Network.Request(id: Network.Request.ID("script"), url: "https://cdn.example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 2 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("image"), - request: Network.Request(id: Network.Request.ID("image"), url: "https://cdn.example.com/photo.png", method: "GET"), - resourceType: .image, - redirectResponse: nil, - timestamp: 3 - )) - await context.apply(.requestWillBeSent( - id: Network.Request.ID("movie"), - request: Network.Request(id: Network.Request.ID("movie"), url: "https://media.example.com/clip.mp4", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 4 - )) - - let mediaCategories: [NetworkRequest.ResourceCategory] = [.image, .media] - let descriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { request in - mediaCategories.contains(request.resourceCategory) - }, - sortBy: [SortDescriptor(\.requestSentTimestamp, order: .reverse)] - ) - let results: WebInspectorFetchedResults = context.fetchedResults(for: descriptor) - - #expect(results.items.map(\.id) == [ - NetworkRequest.ID(Network.Request.ID("image")), - ]) - - await context.apply(.responseReceived( - id: Network.Request.ID("pending-avatar"), - response: Network.Response( - url: "https://api.example.com/avatar.png", - status: 200, - mimeType: "image/png" - ), - resourceType: .xhr, - timestamp: 5 - )) - await context.apply(.responseReceived( - id: Network.Request.ID("movie"), - response: Network.Response( - url: "https://media.example.com/clip.mp4", - status: 200, - mimeType: "application/octet-stream" - ), - resourceType: .fetch, - timestamp: 6 - )) - - #expect(results.items.map(\.id) == [ - NetworkRequest.ID(Network.Request.ID("movie")), - NetworkRequest.ID(Network.Request.ID("image")), - NetworkRequest.ID(Network.Request.ID("pending-avatar")), - ]) -} - -@MainActor -@Test -func networkRequestResourceCategoryUsesResponseHeadersWithoutPendingURLInference() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let requestID = Network.Request.ID("header-avatar") - await context.apply(.requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://api.example.com/avatar.png", method: "GET"), - resourceType: .xhr, - redirectResponse: nil, - timestamp: 1 - )) - let request = try #require(context.registeredRequest(forProxyID: requestID)) - - #expect(request.resourceCategory == .xhrFetch) - - await context.apply(.responseReceived( - id: requestID, - response: Network.Response( - url: "https://api.example.com/avatar", - status: 200, - mimeType: nil, - headers: ["Content-Type": "image/png; charset=utf-8"] - ), - resourceType: .xhr, - timestamp: 2 - )) - - #expect(request.resourceCategory == .image) - #expect(request.searchableText.localizedStandardContains("image/png") == false) - - let scriptVideoID = Network.Request.ID("script-video") - await context.apply(.requestWillBeSent( - id: scriptVideoID, - request: Network.Request(id: scriptVideoID, url: "https://cdn.example.com/player.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 3 - )) - let scriptVideoRequest = try #require(context.registeredRequest(forProxyID: scriptVideoID)) - #expect(scriptVideoRequest.resourceCategory == .script) - - await context.apply(.responseReceived( - id: scriptVideoID, - response: Network.Response( - url: "https://cdn.example.com/player.js", - status: 200, - mimeType: "video/mp4" - ), - resourceType: .script, - timestamp: 4 - )) - - #expect(scriptVideoRequest.resourceCategory == .media) -} - -@MainActor -@Test -func clearNetworkRequestsPublishesResetAndIgnoresClearedEvents() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let firstRequestID = Network.Request.ID("clear-first-request") - let firstModelID = NetworkRequest.ID(firstRequestID) - await runtime.backend.emit( - .requestWillBeSent( - id: firstRequestID, - request: Network.Request(id: firstRequestID, url: "https://example.com/first", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - try await recorder.waitForTransactionCount(1) - - let secondRequestID = Network.Request.ID("clear-second-request") - let secondModelID = NetworkRequest.ID(secondRequestID) - await runtime.backend.emit( - .requestWillBeSent( - id: secondRequestID, - request: Network.Request(id: secondRequestID, url: "https://example.com/second", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 2 - ), - target: target - ) - try await recorder.waitForTransactionCount(2) - #expect(controller.snapshot.itemIDs == [firstModelID, secondModelID]) - - context.clearNetworkRequests() - - try await recorder.waitForTransactionCount(3) - let reset = try #require(recorder.transactions.last) - #expect(reset.isReset) - #expect(reset.oldSnapshot.itemIDs == [firstModelID, secondModelID]) - #expect(reset.newSnapshot.itemIDs == []) - #expect(reset.sectionChanges == []) - #expect(reset.itemChanges == []) - #expect(controller.snapshot.itemIDs == []) - #expect(results.items.isEmpty) - #expect(context.registeredRequest(for: firstModelID) == nil) - #expect(context.registeredRequest(for: secondModelID) == nil) - - let clearedEventBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit( - .responseReceived( - id: firstRequestID, - response: Network.Response(status: 200), - resourceType: .fetch, - timestamp: 3 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: firstRequestID, dataLength: 7, encodedDataLength: 4, timestamp: 4), - target: target - ) - await runtime.backend.emit( - .loadingFinished(id: secondRequestID, timestamp: 5, sourceMapURL: nil, metrics: nil), - target: target - ) - await runtime.backend.emit( - .webSocket(.closed(id: secondRequestID, timestamp: 6)), - target: target - ) - let didProcessClearedEvents = await context.waitForEventPumpAppliedSequenceForTesting( - after: clearedEventBaseline, - count: 4 - ) - #expect(didProcessClearedEvents) - #expect(context.state == .attached) - #expect(results.items.isEmpty) - #expect(recorder.transactions.count == 3) - - let redirectedEventBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit( - .requestWillBeSent( - id: firstRequestID, - request: Network.Request( - id: firstRequestID, - url: "https://example.com/redirected", - method: "GET" - ), - resourceType: .fetch, - redirectResponse: Network.Response(url: "https://example.com/first", status: 302), - timestamp: 7 - ), - target: target - ) - let didProcessRedirectedEvent = await context.waitForEventPumpAppliedSequenceForTesting( - after: redirectedEventBaseline - ) - #expect(didProcessRedirectedEvent) - #expect(results.items.isEmpty) - #expect(recorder.transactions.count == 3) - #expect(context.registeredRequest(for: firstModelID) == nil) - - await runtime.backend.emit( - .requestWillBeSent( - id: firstRequestID, - request: Network.Request( - id: firstRequestID, - url: "https://example.com/reused", - method: "GET" - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 8 - ), - target: target - ) - - try await recorder.waitForTransactionCount(4) - let reusedRequest = try #require(results.items.first) - #expect(reusedRequest.id == firstModelID) - #expect(reusedRequest.url == "https://example.com/reused") - #expect(context.registeredRequest(for: firstModelID) === reusedRequest) -} - -@MainActor -@Test -func networkRequestExposesDataKitQueryableProperties() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - - let apiRequestID = Network.Request.ID("queryable-api-request") - await runtime.backend.emit( - .requestWillBeSent( - id: apiRequestID, - request: Network.Request( - id: apiRequestID, - url: "https://api.example.test/graphql?operation=Feed", - method: "POST" - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .responseReceived( - id: apiRequestID, - response: Network.Response( - url: "https://api.example.test/graphql?operation=Feed", - status: 201, - statusText: "Created", - mimeType: "application/json" - ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - - try await waitUntil { results.items.first?.statusCode == 201 } - let apiRequest = try #require(results.items.first) - #expect(apiRequest.resourceCategory == .xhrFetch) - #expect(apiRequest.statusCode == 201) - #expect(apiRequest.searchableText.localizedStandardContains("graphql")) - #expect(apiRequest.searchableText.localizedStandardContains("POST")) - #expect(apiRequest.searchableText.localizedStandardContains("201")) - #expect(apiRequest.searchableText.localizedStandardContains("Created")) - - let cssRequestID = Network.Request.ID("queryable-css-request") - await runtime.backend.emit( - .responseReceived( - id: cssRequestID, - response: Network.Response( - url: "https://example.test/app.css", - status: 200, - mimeType: "text/css; charset=utf-8" - ), - resourceType: .other, - timestamp: 3 - ), - target: target - ) - - try await waitUntil { results.items.count == 2 } - let cssRequest = try #require(results.items.last) - #expect(cssRequest.resourceCategory == .stylesheet) - #expect(cssRequest.searchableText.localizedStandardContains("app.css")) - #expect(cssRequest.statusCode == 200) -} - -@MainActor -@Test -func fetchedResultsControllerPublishesConsoleInsertUpdateAndDeleteTransactions() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "first" - )), - target: target - ) - - try await recorder.waitForTransactionCount(1) - let firstID = try #require(controller.snapshot.itemIDs.first) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0))]) - - await runtime.backend.emit( - .messageRepeatCountUpdated(count: 3, timestamp: 2), - target: target - ) - - try await recorder.waitForTransactionCount(2) - #expect(recorder.transactions.last?.itemChanges == [.update(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0))]) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "second" - )), - target: target - ) - - try await recorder.waitForTransactionCount(3) - let secondID = try #require(controller.snapshot.itemIDs.last) - #expect(recorder.transactions.last?.itemChanges == [.insert(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1))]) - - await runtime.backend.emit( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - target: target - ) - - try await recorder.waitForTransactionCount(4) - #expect(recorder.transactions.last?.itemChanges == [ - .delete(itemID: secondID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1)), - .delete(itemID: firstID, indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), - ]) - #expect(controller.snapshot.itemIDs == []) -} - -@MainActor -@Test -func fetchedResultsCanBeSectionedByStringKeyPath() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.method) - let controller = WebInspectorFetchedResultsController(fetchedResults: results) - let recorder = FetchedResultsTransactionRecorder(stream: controller.transactions) - defer { recorder.cancel() } - try await recorder.waitUntilStarted() - - let getID = Network.Request.ID("sectioned-get") - await runtime.backend.emit( - .requestWillBeSent( - id: getID, - request: Network.Request(id: getID, url: "https://example.com/get", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - - try await recorder.waitForTransactionCount(1) - #expect(results.sections.map(\.title) == ["GET"]) - #expect(controller.snapshot.sectionIDs == [WebInspectorFetchSectionID(rawValue: "GET")]) - #expect(recorder.transactions.last?.sectionChanges == [ - .insert(sectionID: WebInspectorFetchSectionID(rawValue: "GET"), index: 0) - ]) - #expect(recorder.transactions.last?.itemChanges == [ - .insert( - itemID: NetworkRequest.ID(getID), - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 0) - ) - ]) - - let postID = Network.Request.ID("sectioned-post") - await runtime.backend.emit( - .requestWillBeSent( - id: postID, - request: Network.Request(id: postID, url: "https://example.com/post", method: "POST"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 2 - ), - target: target - ) - - try await recorder.waitForTransactionCount(2) - #expect(results.sections.map(\.title) == ["GET", "POST"]) - #expect(recorder.transactions.last?.sectionChanges == [ - .insert(sectionID: WebInspectorFetchSectionID(rawValue: "POST"), index: 1) - ]) - #expect(recorder.transactions.last?.itemChanges == [ - .insert( - itemID: NetworkRequest.ID(postID), - indexPath: WebInspectorFetchedResultsIndexPath(section: 1, item: 0) - ) - ]) - - let secondGetID = Network.Request.ID("sectioned-second-get") - await runtime.backend.emit( - .requestWillBeSent( - id: secondGetID, - request: Network.Request(id: secondGetID, url: "https://example.com/get-2", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 3 - ), - target: target - ) - - try await recorder.waitForTransactionCount(3) - #expect(results.sections.map(\.items.count) == [2, 1]) - #expect(recorder.transactions.last?.sectionChanges == []) - #expect(recorder.transactions.last?.itemChanges == [ - .insert( - itemID: NetworkRequest.ID(secondGetID), - indexPath: WebInspectorFetchedResultsIndexPath(section: 0, item: 1) - ) - ]) -} - -@MainActor -@Test -func fetchedResultsCanBeSectionedByRawRepresentableKeyPath() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults(sectionBy: \.level) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "warning" - )), - target: target - ) - - try await waitUntil { results.sections.first?.title == "warning" } - #expect(results.sections.map(\.id) == [WebInspectorFetchSectionID(rawValue: "warning")]) - #expect(results.sections.first?.items.first?.text == "warning") -} - -@MainActor -@Test -func fetchedResultsTransactionDiffsMovesByItemID() { - let first = NetworkRequest.ID(Network.Request.ID("first")) - let second = NetworkRequest.ID(Network.Request.ID("second")) - let oldSnapshot = WebInspectorFetchedResultsSnapshot(itemIDs: [first, second]) - let newSnapshot = WebInspectorFetchedResultsSnapshot(itemIDs: [second, first]) - - let transaction = WebInspectorFetchedResultsTransaction( - oldSnapshot: oldSnapshot, - newSnapshot: newSnapshot - ) - - #expect(transaction.itemChanges == [ - .move(itemID: second, from: WebInspectorFetchedResultsIndexPath(section: 0, item: 1), to: WebInspectorFetchedResultsIndexPath(section: 0, item: 0)), - .move(itemID: first, from: WebInspectorFetchedResultsIndexPath(section: 0, item: 0), to: WebInspectorFetchedResultsIndexPath(section: 0, item: 1)), - ]) -} - -@MainActor -@Test -func selectingDOMNodeLoadsCSSStylesAndComputedProperties() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: elementID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - childNodeCount: 0 - ) - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await enqueueCSSStyleReplies(on: runtime.backend) - - context.select(element) - - let styles = try #require(element.elementStyles) - #expect(styles.phase == .loading) - try await waitUntil { styles.phase == .loaded } - #expect(styles.sections.map(\.title) == [".card"]) - #expect(styles.sections.map(\.kind) == [.rule]) - #expect(styles.computedProperties.map(\.name) == ["display"]) - - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "enable")) == false) - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode"))) - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getInlineStylesForNode"))) - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getComputedStyleForNode"))) -} - -@MainActor -@Test -func selectingDOMNodeRetriesCSSStyleLoadAfterEnablingAgent() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: elementID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - childNodeCount: 0 - ) - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await runtime.backend.enqueueFailure( - WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "getMatchedStylesForNode", - message: "CSS agent is not enabled." - ), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue((), for: "CSS", method: "enable") - await enqueueCSSStyleReplies(on: runtime.backend) - - context.select(element) - - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(styles.sections.map(\.title) == [".card"]) - #expect(styles.computedProperties.map(\.name) == ["display"]) - - let cssCommands = await runtime.backend.recordedCommands() - .filter { $0.domain == "CSS" } - #expect(cssCommands == [ - RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode"), - RecordedCommand(domain: "CSS", method: "enable"), - RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode"), - RecordedCommand(domain: "CSS", method: "getInlineStylesForNode"), - RecordedCommand(domain: "CSS", method: "getComputedStyleForNode"), - ]) -} - -@MainActor -@Test -func selectingFrameScopedDOMNodeRetriesCSSStyleLoadByEnablingFrameAgent() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let frameTargetRawValue = "frame-css-agent" - let elementID = DOM.Node.ID("frame-styled-node", scopedToTargetRawValue: frameTargetRawValue) - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node( - id: elementID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - childNodeCount: 0 - ) - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await runtime.backend.enqueueFailure( - WebInspectorProxyError.commandFailed( - domain: "CSS", - method: "getMatchedStylesForNode", - message: "CSS agent is not enabled." - ), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue((), for: "CSS", method: "enable") - await enqueueCSSStyleReplies(on: runtime.backend) - - context.select(element) - - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - let enableCommand = await runtime.backend.recordedCommands().first { - $0.domain == "CSS" && $0.method == "enable" - } - #expect(enableCommand?.targetID == WebInspectorTarget.ID(frameTargetRawValue)) -} - -@MainActor -@Test -func selectingNonElementDOMNodeDoesNotRequestCSSStyles() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (_, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - - context.select(document) - - #expect(document.elementStyles == nil) - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode")) == false) - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "getComputedStyleForNode")) == false) -} - -@MainActor -@Test -func cssEventsAndSelectedDOMMutationsMarkSelectedStylesStale() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let selectedID = DOM.Node.ID("selected") - let otherID = DOM.Node.ID("other") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: selectedID, nodeType: 1, nodeName: "DIV", localName: "div"), - DOM.Node(id: otherID, nodeType: 1, nodeName: "SPAN", localName: "span"), - ]), - target: target - ) - try await waitUntil { - guard case let .loaded(children) = document.children else { - return false - } - return children.count == 2 - } - guard case let .loaded(children) = document.children else { - Issue.record("Expected loaded document children.") - return - } - let selected = try #require(children.first { $0.id == DOMNode.ID(selectedID) }) - - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(selected) - let styles = try #require(selected.elementStyles) - try await waitUntil { styles.phase == .loaded } - - func reloadSelectedStyles() async throws { - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(selected) - try await waitUntil { styles.phase == .loaded } - } - - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - await runtime.backend.emit( - .styleSheetAdded(CSS.StyleSheetHeader( - styleSheetID: CSS.StyleSheet.ID("sheet-1"), - origin: CSS.Origin(rawValue: "author") - )), - target: target - ) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - await runtime.backend.emit(.styleSheetRemoved(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - await runtime.backend.emit(.mediaQueryResultChanged, target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - let otherAttributeBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit(.attributeModified(otherID, name: "class", value: "ignored"), target: target) - let didProcessOtherAttribute = await context.waitForEventPumpAppliedSequenceForTesting( - after: otherAttributeBaseline - ) - #expect(didProcessOtherAttribute) - #expect(styles.phase == .loaded) - - let otherLayoutBaseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit(.nodeLayoutFlagsChanged(otherID), target: target) - let didProcessOtherLayout = await context.waitForEventPumpAppliedSequenceForTesting( - after: otherLayoutBaseline - ) - #expect(didProcessOtherLayout) - #expect(styles.phase == .loaded) - - await runtime.backend.emit(.nodeLayoutFlagsChanged(selectedID), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - try await reloadSelectedStyles() - - await runtime.backend.emit(.attributeModified(selectedID, name: "class", value: "changed"), target: target) - try await waitUntil { styles.phase == .needsRefresh } -} - -@MainActor -@Test -func cssInvalidationDuringStyleFetchIsNotOverwrittenByStaleResult() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - let computedGate = WebInspectorTestGate() - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await runtime.backend.enqueue( - CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: CSS.Style(id: CSS.Style.ID("style-1")) - ) - ]), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue(CSS.InlineStyles(), for: "CSS", method: "getInlineStylesForNode") - await runtime.backend.hold(domain: "CSS", method: "getComputedStyleForNode", gate: computedGate) - await runtime.backend.enqueue( - [CSS.ComputedProperty(name: "display", value: "grid")], - for: "CSS", - method: "getComputedStyleForNode" - ) - - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { - await runtime.backend.recordedCommands().contains( - RecordedCommand(domain: "CSS", method: "getComputedStyleForNode") - ) - } - - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - - await computedGate.open() - _ = await runtime.backend.waitForCompletedCommands(domain: "CSS", method: "getComputedStyleForNode", count: 1) - #expect(styles.phase == .needsRefresh) - #expect(styles.computedProperties.isEmpty) -} - -@MainActor -@Test -func selectingDOMNodeLoadsInlineAndAttributesStyleSections() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await runtime.backend.enqueue( - CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: CSS.Style(id: CSS.Style.ID("style-rule"), cssText: "display: grid;") - ) - ]), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue( - CSS.InlineStyles( - inlineStyle: CSS.Style( - id: CSS.Style.ID("style-inline"), - properties: [ - CSS.Property( - id: CSS.Property.ID("inline-color"), - name: "color", - value: "red", - text: "color: red;", - isEditable: true - ) - ], - cssText: "color: red;", - isEditable: true - ), - attributesStyle: CSS.Style( - id: CSS.Style.ID("style-attributes"), - properties: [ - CSS.Property(id: CSS.Property.ID("attribute-width"), name: "width", value: "100px") - ] - ) - ), - for: "CSS", - method: "getInlineStylesForNode" - ) - await runtime.backend.enqueue( - [CSS.ComputedProperty(name: "display", value: "grid")], - for: "CSS", - method: "getComputedStyleForNode" - ) - - context.select(element) - - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - #expect(styles.sections.map(\.kind) == [.inlineStyle, .rule, .attributesStyle]) - #expect(styles.sections.map(\.title) == ["element.style", ".card", "Attributes"]) - #expect(styles.sections.map(\.isEditable) == [true, false, false]) -} - -@MainActor -@Test -func styleSheetChangedWhileHydrationActiveTriggersImmediateRefetch() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - context.css.setStyleHydrationActive(true) - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - await enqueueCSSStyleReplies(on: runtime.backend) - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - - try await waitUntil { - await matchedStylesCommandCount(on: runtime.backend) == 2 - } - try await waitUntil { styles.phase == .loaded } -} - -@MainActor -@Test -func styleSheetChangedWhileHydrationInactiveDefersRefetchUntilActivation() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - #expect(styles.phase == .needsRefresh) - #expect(await matchedStylesCommandCount(on: runtime.backend) == 1) - - await enqueueCSSStyleReplies(on: runtime.backend) - context.css.setStyleHydrationActive(true) - - try await waitUntil { styles.phase == .loaded } - #expect(await matchedStylesCommandCount(on: runtime.backend) == 2) -} - -@MainActor -@Test -func requestSetCSSPropertyTogglesDeclarationAndRefreshesStyles() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - context.css.setStyleHydrationActive(true) - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - let disabledStyle = CSS.Style( - id: CSS.Style.ID("style-1"), - properties: [ - CSS.Property( - id: CSS.Property.ID("property-1"), - name: "display", - value: "grid", - text: "/* display: grid; */", - status: .disabled, - isEditable: true - ) - ], - cssText: "/* display: grid; */", - isEditable: true - ) - await runtime.backend.enqueue(disabledStyle, for: "CSS", method: "setStyleText") - await runtime.backend.enqueue( - CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: disabledStyle - ) - ]), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue(CSS.InlineStyles(), for: "CSS", method: "getInlineStylesForNode") - await runtime.backend.enqueue( - [CSS.ComputedProperty(name: "display", value: "grid")], - for: "CSS", - method: "getComputedStyleForNode" - ) - - let propertyID = try #require(styles.sections.first?.style.properties.first?.id) - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - #expect(context.css.requestSetProperty(propertyID, enabled: false)) - - try await waitUntil { - await matchedStylesCommandCount(on: runtime.backend) == 2 - } - try await waitUntil { styles.phase == .loaded } - - let commands = await runtime.backend.recordedCommands() - let setStyleText = try #require(commands.last { $0 == RecordedCommand(domain: "CSS", method: "setStyleText") }) - let payload = try #require(setStyleText.payload.cast(as: CSS.SetStyleTextPayload.self)) - #expect(payload.id == CSS.Style.ID("style-1")) - #expect(payload.text == "/* display: grid; */") - let undoMarks = commands.filter { $0.domain == "DOM" && $0.method == "markUndoableState" } - #expect(undoMarks.count == 1) - #expect(undoMarks.first?.targetID == target.id) - - let property = try #require(styles.sections.first?.style.properties.first) - #expect(property.status == .disabled) - #expect(property.isModifiedByInspector) -} - -@MainActor -@Test -func setCSSDeclarationTextRewritesStyleTextAndMarksUndoableState() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await enqueueCSSStyleReplies(on: runtime.backend) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - let updatedStyle = CSS.Style( - id: CSS.Style.ID("style-1"), - properties: [ - CSS.Property( - id: CSS.Property.ID("property-1"), - name: "display", - value: "flex", - text: "display: flex;", - isEditable: true, - isModifiedByInspector: true - ) - ], - cssText: "display: flex;", - isEditable: true - ) - await runtime.backend.enqueue(updatedStyle, for: "CSS", method: "setStyleText") - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - - let propertyID = try #require(styles.sections.first?.style.properties.first?.id) - try await context.css.setDeclarationText("display: flex;", for: propertyID) - - let commands = await runtime.backend.recordedCommands() - let setStyleText = try #require(commands.last { $0 == RecordedCommand(domain: "CSS", method: "setStyleText") }) - let payload = try #require(setStyleText.payload.cast(as: CSS.SetStyleTextPayload.self)) - #expect(payload.id == CSS.Style.ID("style-1")) - #expect(payload.text == "display: flex;") - let undoMarks = commands.filter { $0.domain == "DOM" && $0.method == "markUndoableState" } - #expect(undoMarks.count == 1) - #expect(undoMarks.first?.targetID == target.id) - - let property = try #require(styles.sections.first?.style.properties.first) - #expect(property.value == "flex") - #expect(property.text == "display: flex;") - #expect(property.isModifiedByInspector) - #expect(styles.phase == .needsRefresh) -} - -@MainActor -@Test -func cssRuleSelectorEditsMarkUndoableStateOnOwningTarget() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (_, context) = try await startContext(runtime: runtime) - let frameTarget = await runtime.proxy.installTargetForTesting(kind: .frame) - let proxyRuleID = CSS.Rule.ID("frame-rule", scopedToTargetRawValue: frameTarget.id.rawValue) - let ruleID = CSSStyleRule.ID(proxyRuleID) - - await runtime.backend.enqueue( - CSS.Rule( - id: proxyRuleID, - selectorList: CSS.Rule.SelectorList(selectors: [".updated"], text: ".updated"), - origin: CSS.Origin(rawValue: "regular"), - style: CSS.Style(id: CSS.Style.ID("frame-style", scopedToTargetRawValue: frameTarget.id.rawValue)) - ), - for: "CSS", - method: "setRuleSelector" - ) - await runtime.backend.enqueue((), for: "DOM", method: "markUndoableState") - - try await context.css.setRuleSelector(".updated", for: ruleID) - - let commands = await runtime.backend.recordedCommands() - let setRuleSelector = try #require(commands.first { $0.domain == "CSS" && $0.method == "setRuleSelector" }) - #expect(setRuleSelector.targetID == frameTarget.id) - #expect(setRuleSelector.route == RoutingTargetID(frameTarget.id.rawValue)) - #expect(setRuleSelector.payload.cast(as: CSS.SetRuleSelectorPayload.self)?.id == proxyRuleID) - - let markUndoableState = try #require(commands.first { $0.domain == "DOM" && $0.method == "markUndoableState" }) - #expect(markUndoableState.targetID == frameTarget.id) - #expect(markUndoableState.route == RoutingTargetID(frameTarget.id.rawValue)) -} - -@MainActor -@Test -func requestSetCSSPropertyRefusesStaleAndNonEditableProperties() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let document = try #require(context.rootNode) - let elementID = DOM.Node.ID("styled-node") - - await runtime.backend.emit( - .setChildNodes(parent: document.id.proxyID, nodes: [ - DOM.Node(id: elementID, nodeType: 1, nodeName: "DIV", localName: "div") - ]), - target: target - ) - let element = try await waitForChild(in: context) - - await runtime.backend.enqueue( - CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: ["div"], text: "div"), - origin: CSS.Origin(rawValue: "user-agent"), - style: CSS.Style( - id: CSS.Style.ID("style-ua"), - properties: [ - CSS.Property( - id: CSS.Property.ID("property-ua"), - name: "display", - value: "block", - text: "display: block;", - isEditable: true - ) - ], - cssText: "display: block;", - isEditable: true - ) - ), - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: CSS.Style( - id: CSS.Style.ID("style-1"), - properties: [ - CSS.Property( - id: CSS.Property.ID("property-1"), - name: "display", - value: "grid", - text: "display: grid;", - isEditable: true - ) - ], - cssText: "display: grid;", - isEditable: true - ) - ), - ]), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await runtime.backend.enqueue(CSS.InlineStyles(), for: "CSS", method: "getInlineStylesForNode") - await runtime.backend.enqueue( - [CSS.ComputedProperty(name: "display", value: "grid")], - for: "CSS", - method: "getComputedStyleForNode" - ) - context.select(element) - let styles = try #require(element.elementStyles) - try await waitUntil { styles.phase == .loaded } - - let userAgentSection = try #require(styles.sections.first { $0.title == "div" }) - #expect(userAgentSection.isEditable == false) - let userAgentPropertyID = try #require(userAgentSection.style.properties.first?.id) - #expect(context.css.requestSetProperty(userAgentPropertyID, enabled: false) == false) - - let editableSection = try #require(styles.sections.first { $0.title == ".card" }) - let editablePropertyID = try #require(editableSection.style.properties.first?.id) - - await runtime.backend.emit(.styleSheetChanged(CSS.StyleSheet.ID("sheet-1")), target: target) - try await waitUntil { styles.phase == .needsRefresh } - #expect(context.css.requestSetProperty(editablePropertyID, enabled: false) == false) - - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "CSS", method: "setStyleText")) == false) -} - -@MainActor -@Test -func removingLoadedChildPurgesDescendantsFromIdentityMap() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let childID = DOM.Node.ID("child") - let grandchildID = DOM.Node.ID("grandchild") - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "SECTION", - localName: "section", - childNodeCount: 1, - children: [ - DOM.Node( - id: grandchildID, - nodeType: 1, - nodeName: "SPAN", - localName: "span" - ) - ] - ) - ]), - target: target - ) - - try await waitUntil { - context.node(for: DOMNode.ID(grandchildID)) != nil - } - - await runtime.backend.emit( - .childNodeRemoved(parent: documentID, node: childID), - target: target - ) - - try await waitUntil { - context.node(for: DOMNode.ID(childID)) == nil - && context.node(for: DOMNode.ID(grandchildID)) == nil - } -} - -@MainActor -@Test -func setChildNodesPreservesReparentedDescendantIdentity() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let oldParentID = DOM.Node.ID("old-parent") - let newParentID = DOM.Node.ID("new-parent") - let movedChildID = DOM.Node.ID("moved-child") - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: oldParentID, - nodeType: 1, - nodeName: "SECTION", - localName: "section", - children: [ - DOM.Node(id: movedChildID, nodeType: 1, nodeName: "SPAN", localName: "span") - ] - ) - ]), - target: target - ) - - try await waitUntil { context.node(for: DOMNode.ID(movedChildID)) != nil } - let movedChild = try #require(context.node(for: DOMNode.ID(movedChildID))) - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: newParentID, - nodeType: 1, - nodeName: "ARTICLE", - localName: "article", - children: [ - DOM.Node(id: movedChildID, nodeType: 1, nodeName: "SPAN", localName: "span") - ] - ) - ]), - target: target - ) - - try await waitUntil { - context.node(for: DOMNode.ID(oldParentID)) == nil - && context.node(for: DOMNode.ID(newParentID)) != nil - } - #expect(context.node(for: DOMNode.ID(movedChildID)) === movedChild) -} - -@MainActor -@Test -func setChildNodesPrunesOmittedDescendantsWhenReusingChildNode() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let childID = DOM.Node.ID("child") - let removedSpanID = DOM.Node.ID("removed-span") - let removedEmID = DOM.Node.ID("removed-em") - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - children: [ - DOM.Node(id: removedSpanID, nodeType: 1, nodeName: "SPAN", localName: "span"), - DOM.Node(id: removedEmID, nodeType: 1, nodeName: "EM", localName: "em"), - ] - ) - ]), - target: target - ) - - try await waitUntil { context.node(for: DOMNode.ID(removedEmID)) != nil } - let child = try #require(context.node(for: DOMNode.ID(childID))) - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 0) - ]), - target: target - ) - - try await waitUntil { - context.node(for: DOMNode.ID(removedSpanID)) == nil - && context.node(for: DOMNode.ID(removedEmID)) == nil - } - #expect(context.node(for: DOMNode.ID(childID)) === child) -} - -@MainActor -@Test -func setChildNodesPreservesLoadedDescendantsForShallowRefresh() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let documentID = DOM.Node.ID("document") - let childID = DOM.Node.ID("child") - let grandchildID = DOM.Node.ID("grandchild") - - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.rootNode != nil } - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node( - id: childID, - nodeType: 1, - nodeName: "DIV", - localName: "div", - children: [ - DOM.Node(id: grandchildID, nodeType: 1, nodeName: "SPAN", localName: "span") - ] - ) - ]), - target: target - ) - - try await waitUntil { context.node(for: DOMNode.ID(grandchildID)) != nil } - let grandchild = try #require(context.node(for: DOMNode.ID(grandchildID))) - - await runtime.backend.emit( - .setChildNodes(parent: documentID, nodes: [ - DOM.Node(id: childID, nodeType: 1, nodeName: "DIV", localName: "div", childNodeCount: 1) - ]), - target: target - ) - - try await waitUntil { - guard let child = context.node(for: DOMNode.ID(childID)), - case let .loaded(children) = child.children else { - return false - } - return children.first === grandchild - } - #expect(context.node(for: DOMNode.ID(grandchildID)) === grandchild) -} - -@MainActor -@Test -func closeDuringStartupKeepsContextDetached() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let gate = WebInspectorTestGate() - let documentID = DOM.Node.ID("document") - - await runtime.backend.hold(domain: "DOM", method: "getDocument", gate: gate) - await enqueueStartupReplies( - on: runtime.backend, - document: DOM.Node(id: documentID, nodeType: 9, nodeName: "#document") - ) - await enqueueDomainDisableReplies(on: runtime.backend) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - let startupTask = try #require(context.startupTaskForTesting()) - try await waitUntil { - await runtime.backend.recordedCommands() - .contains(RecordedCommand(domain: "DOM", method: "getDocument")) - } - - await container.close() - #expect(context.state == .detached) - - await gate.open() - await startupTask.value - - #expect(context.state == .detached) - #expect(context.rootNode == nil) - - let commands = await runtime.backend.recordedCommands() - #expect(commands == Array(startupCommands.prefix(5)) + [ - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Network", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ]) -} - -@MainActor -@Test -func stopDuringStartupReleasesLateRuntimeAcquire() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let gate = WebInspectorTestGate() - - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: gate) - await runtime.backend.enqueue((), for: "Inspector", method: "enable") - await runtime.backend.enqueue((), for: "Inspector", method: "initialized") - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - ] - } - - await runtime.backend.enqueue((), for: "Inspector", method: "disable") - await context.stop() - #expect(context.state == .detached) - - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - await gate.open() - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Inspector", method: "disable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ] - } - #expect(context.state == .detached) -} - -@MainActor -@Test -func domainEnablementReleaseDuringPendingEnableDisablesAfterEnableCompletes() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let gate = WebInspectorTestGate() - - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: gate) - - let acquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable") - ] - } - - let releaseTask = Task { - await registry.release(.runtime, on: target) - } - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - await gate.open() - - try await acquireTask.value - #expect(await releaseTask.value == nil) - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ]) -} - -@MainActor -@Test -func domainEnablementAcquireWaitsForFinalReleaseDisable() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let disableGate = WebInspectorTestGate() - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - try await registry.acquire(.runtime, on: target) - await runtime.backend.hold(domain: "Runtime", method: "disable", gate: disableGate) - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - - let releaseTask = Task { - await registry.release(.runtime, on: target) - } - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ] - } - - let acquireWaitBaseline = await registry.acquireWaitingForDisableSequenceForTesting - let acquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - await registry.waitForAcquireWaitingForDisableForTesting(after: acquireWaitBaseline) - #expect(await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ]) - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await disableGate.open() - - #expect(await releaseTask.value == nil) - try await acquireTask.value - #expect(await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Runtime", method: "enable"), - ]) -} - -@MainActor -@Test -func domainEnablementAcquireWaitsForPendingReleaseDisable() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - let enableGate = WebInspectorTestGate() - let disableGate = WebInspectorTestGate() - - await runtime.backend.hold(domain: "Runtime", method: "enable", gate: enableGate) - await runtime.backend.hold(domain: "Runtime", method: "disable", gate: disableGate) - - let firstAcquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable") - ] - } - - let releaseTask = Task { - await registry.release(.runtime, on: target) - } - let acquireWaitBaseline = await registry.acquireWaitingForDisableSequenceForTesting - let secondAcquireTask = Task { - try await registry.acquire(.runtime, on: target) - } - await registry.waitForAcquireWaitingForDisableForTesting(after: acquireWaitBaseline) - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - await enableGate.open() - try await waitUntil { - await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ] - } - #expect(await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ]) - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await disableGate.open() - - try await firstAcquireTask.value - #expect(await releaseTask.value == nil) - try await secondAcquireTask.value - #expect(await runtime.backend.recordedCommands() == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Runtime", method: "enable"), - ]) -} - -@MainActor -@Test -func domainEnablementDiscardLeasePreservesSharedEnabledLease() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let registry = WebInspectorDomainEnablementRegistry() - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - try await registry.acquire(.runtime, on: target) - try await registry.acquire(.runtime, on: target) - - await registry.discardLease(.runtime, on: target) - - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - #expect(await registry.release(.runtime, on: target) == nil) - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "disable"), - ]) -} - -@MainActor -@Test -func domainLeaseRetargetInterleavingReenablesCommittedPageBinding() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let registry = WebInspectorDomainEnablementRegistry() - let oldPage = WebInspectorTarget( - id: .currentPage, - kind: .page, - frameID: nil, - isProvisional: false, - proxy: runtime.proxy, - route: .currentPage, - pageBindingID: "page-old" - ) - let newPage = WebInspectorTarget( - id: .currentPage, - kind: .page, - frameID: nil, - isProvisional: false, - proxy: runtime.proxy, - route: .currentPage, - pageBindingID: "page-new" - ) - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - try await registry.acquire(.runtime, on: oldPage) - try await registry.acquire(.runtime, on: oldPage) - - await registry.discardLease(.runtime, on: oldPage) - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - try await registry.acquire(.runtime, on: newPage) - - await registry.discardLease(.runtime, on: oldPage) - try await registry.acquire(.runtime, on: newPage) - - let commands = await runtime.backend.recordedCommands() - #expect(commands == [ - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Runtime", method: "enable"), - ]) -} - -@MainActor -@Test -func networkEventsPopulateAllRequestsInOrder() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-1") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request( - id: requestID, - url: "https://example.com/data.json", - method: "GET", - headers: ["Accept": "application/json"] - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - url: "https://cdn.example.com/data.json", - status: 200, - statusText: "OK", - mimeType: "application/json", - headers: ["Content-Type": "application/json"], - source: Network.Source(rawValue: "network") - ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 12, encodedDataLength: 5, timestamp: 3), - target: target - ) - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.count == 1 && results.items.first?.state == .finished - } - let request = try #require(results.items.first) - #expect(request.url == "https://example.com/data.json") - #expect(request.method == "GET") - #expect(request.resourceType == .fetch) - #expect(request.status == 200) - #expect(request.statusText == "OK") - #expect(request.responseURL == "https://cdn.example.com/data.json") - #expect(request.mimeType == "application/json") - #expect(request.responseSource == "network") - #expect(request.hasResponse) - #expect(request.hasResponseBody) - #expect(request.requestHeaders["Accept"] == "application/json") - #expect(request.responseHeaders["Content-Type"] == "application/json") - #expect(request.requestSentTimestamp == 1) - #expect(request.responseReceivedTimestamp == 2) - #expect(request.lastDataReceivedTimestamp == 3) - #expect(request.finishedOrFailedTimestamp == 4) - #expect(request.decodedDataLength == 12) - #expect(request.encodedDataLength == 5) - #expect(context.registeredRequest(for: request.id) === request) -} - -@MainActor -@Test -func responseReceivedWithoutRequestWillBeSentCreatesRequest() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("response-first-request") - - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - url: "https://example.com/late.css", - status: 200, - statusText: "OK", - mimeType: "text/css", - headers: ["Content-Type": "text/css"], - source: Network.Source(rawValue: "network"), - requestHeaders: ["Accept": "text/css"] - ), - resourceType: .stylesheet, - timestamp: 2 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 9, encodedDataLength: 4, timestamp: 3), - target: target - ) - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 4, sourceMapURL: nil, metrics: nil), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.state == .finished - } - let request = try #require(results.items.first) - #expect(request.id == NetworkRequest.ID(requestID)) - #expect(request.url == "https://example.com/late.css") - #expect(request.method == "GET") - #expect(request.resourceType == .stylesheet) - #expect(request.status == 200) - #expect(request.mimeType == "text/css") - #expect(request.requestSentTimestamp == 2) - #expect(request.responseReceivedTimestamp == 2) - #expect(request.lastDataReceivedTimestamp == 3) - #expect(request.finishedOrFailedTimestamp == 4) - #expect(request.requestHeaders["Accept"] == "text/css") - #expect(request.responseHeaders["Content-Type"] == "text/css") - #expect(request.decodedDataLength == 9) - #expect(request.encodedDataLength == 4) -} - -@MainActor -@Test -func responseReceivedWithoutResourceTypePreservesRequestResourceType() async throws { - let context = WebInspectorContext.preview(isolation: MainActor.shared) - let requestID = Network.Request.ID("response-type-preserved-request") - let modelID = NetworkRequest.ID(requestID) - - await context.apply(.requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/image.png", method: "GET"), - resourceType: .image, - redirectResponse: nil, - timestamp: 1 - )) - await context.apply(.responseReceived( - id: requestID, - response: Network.Response( - url: "https://example.com/image.png", - status: 200, - statusText: "OK", - mimeType: "image/png" - ), - resourceType: nil, - timestamp: 2 - )) - - let request = try #require(context.registeredRequest(for: modelID)) - #expect(request.resourceType == .image) - #expect(request.responseReceivedTimestamp == 2) -} - -@MainActor -@Test -func loadingFinishedStoresTerminalMetadataAndOverridesDataTotals() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-with-terminal-metadata") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/app.js", method: "GET"), - resourceType: .script, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 2, timestamp: 2), - target: target - ) - await runtime.backend.emit( - .loadingFinished( - id: requestID, - timestamp: 3, - sourceMapURL: "app.js.map", - metrics: Network.Metrics( - networkProtocol: "h3", - remoteAddress: "203.0.113.20:443", - encodedDataLength: 9, - decodedBodyLength: 12 - ) - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.state == .finished - } - let request = try #require(results.items.first) - #expect(request.sourceMapURL == "app.js.map") - #expect(request.metrics?.networkProtocol == "h3") - #expect(request.metrics?.remoteAddress == "203.0.113.20:443") - #expect(request.metrics?.encodedDataLength == 9) - #expect(request.metrics?.decodedBodyLength == 12) - #expect(request.lastDataReceivedTimestamp == 2) - #expect(request.finishedOrFailedTimestamp == 3) - #expect(request.decodedDataLength == 12) - #expect(request.encodedDataLength == 9) -} - -@MainActor -@Test -func loadingFinishedClampsNegativeMetricTotals() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-with-negative-terminal-metrics") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/negative", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 5, encodedDataLength: 4, timestamp: 2), - target: target - ) - await runtime.backend.emit( - .loadingFinished( - id: requestID, - timestamp: 3, - sourceMapURL: nil, - metrics: Network.Metrics(encodedDataLength: -8, decodedBodyLength: -13) - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.state == .finished - } - let request = try #require(results.items.first) - #expect(request.metrics?.encodedDataLength == -8) - #expect(request.metrics?.decodedBodyLength == -13) - #expect(request.decodedDataLength == 0) - #expect(request.encodedDataLength == 0) -} - -@MainActor -@Test -func repeatedRequestWillBeSentClearsStaleResponseFields() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("redirected-request") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/redirect", method: "GET"), - resourceType: .document, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - url: "https://example.com/redirect", - status: 302, - statusText: "Found", - mimeType: "text/html", - headers: ["Location": "https://example.com/final"], - source: Network.Source(rawValue: "network") - ), - resourceType: .document, - timestamp: 2 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.first?.status == 302 - } - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/final", method: "GET"), - resourceType: .document, - redirectResponse: Network.Response(status: 302), - timestamp: 3 - ), - target: target - ) - - let request = try #require(results.items.first) - try await waitUntil { - request.url == "https://example.com/final" && request.state == .pending - } - #expect(request.status == nil) - #expect(request.statusText == nil) - #expect(request.responseURL == nil) - #expect(request.mimeType == nil) - #expect(request.responseSource == nil) - #expect(request.responseHeaders.isEmpty) - #expect(request.requestSentTimestamp == 3) - #expect(request.responseReceivedTimestamp == nil) - #expect(request.lastDataReceivedTimestamp == nil) - #expect(request.finishedOrFailedTimestamp == nil) - #expect(request.decodedDataLength == 0) - #expect(request.encodedDataLength == 0) - #expect(request.responseBody.phase == .available) - #expect(request.responseBody.text == nil) - #expect(request.redirects.count == 1) - #expect(request.redirects.first?.request.url == "https://example.com/redirect") - #expect(request.redirects.first?.response.status == 302) - #expect(request.redirects.first?.timestamp == 3) -} - -@MainActor -@Test -func completedRequestDoesNotTreatLaterRequestWillBeSentAsRedirect() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("reused-request") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/first", method: "GET"), - resourceType: .document, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .loadingFinished( - id: requestID, - timestamp: 2, - sourceMapURL: "first.map", - metrics: Network.Metrics(encodedDataLength: 20, decodedBodyLength: 40) - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.first?.state == .finished } - let request = try #require(results.items.first) - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/second", method: "GET"), - resourceType: .document, - redirectResponse: Network.Response(status: 302), - timestamp: 3 - ), - target: target - ) - - try await waitUntil { - request.url == "https://example.com/second" && request.state == .pending - } - #expect(results.items.first === request) - #expect(request.redirects.isEmpty) - #expect(request.requestSentTimestamp == 3) - #expect(request.finishedOrFailedTimestamp == nil) - #expect(request.sourceMapURL == nil) - #expect(request.metrics == nil) -} - -@MainActor -@Test -func loadingFailedStoresFailureTimestampAndClampsDataLength() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("failed-request") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request(id: requestID, url: "https://example.com/fail", method: "GET"), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: -10, encodedDataLength: -20, timestamp: 2), - target: target - ) - await runtime.backend.emit( - .loadingFailed(id: requestID, errorText: "cancelled", canceled: true, timestamp: 3), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.first?.state == .failed(errorText: "cancelled", canceled: true) } - let request = try #require(results.items.first) - #expect(request.requestSentTimestamp == 1) - #expect(request.lastDataReceivedTimestamp == 2) - #expect(request.finishedOrFailedTimestamp == 3) - #expect(request.decodedDataLength == 0) - #expect(request.encodedDataLength == 0) -} - -@MainActor -@Test -func memoryCacheEventCreatesFinishedCachedRequestFromResponse() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("cached-request") - - await runtime.backend.emit( - .requestServedFromMemoryCache( - id: requestID, - response: Network.Response( - url: "https://example.com/cached.css", - status: 200, - statusText: "OK", - mimeType: "text/css", - headers: ["Content-Type": "text/css"], - source: Network.Source(rawValue: "memory-cache"), - requestHeaders: ["Accept": "text/css"], - bodySize: 2048 - ), - resourceType: .stylesheet, - timestamp: 5 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.count == 1 && results.items.first?.state == .finished - } - let request = try #require(results.items.first) - #expect(request.url == "https://example.com/cached.css") - #expect(request.method == "GET") - #expect(request.resourceType == .stylesheet) - #expect(request.status == 200) - #expect(request.statusText == "OK") - #expect(request.responseURL == "https://example.com/cached.css") - #expect(request.mimeType == "text/css") - #expect(request.responseSource == "memory-cache") - #expect(request.requestHeaders["Accept"] == "text/css") - #expect(request.responseHeaders["Content-Type"] == "text/css") - #expect(request.requestSentTimestamp == 5) - #expect(request.responseReceivedTimestamp == 5) - #expect(request.lastDataReceivedTimestamp == nil) - #expect(request.finishedOrFailedTimestamp == 5) - #expect(request.decodedDataLength == 2048) - #expect(request.encodedDataLength == 2048) - #expect(request.responseBody.phase == .available) - #expect(context.registeredRequest(for: request.id) === request) -} - -@MainActor -@Test -func memoryCacheEventWithoutURLForNewRequestIsSkipped() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - - await runtime.backend.emit( - .requestServedFromMemoryCache( - id: Network.Request.ID("cached-request-without-url"), - response: Network.Response(status: 200), - resourceType: nil, - timestamp: 5 - ), - target: target - ) - await runtime.backend.emit( - .requestServedFromMemoryCache( - id: Network.Request.ID("cached-request-with-url"), - response: Network.Response(url: "https://example.com/cached.css", status: 200), - resourceType: nil, - timestamp: 6 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - #expect(results.items.first?.url == "https://example.com/cached.css") - #expect(context.state == .attached) -} - -@MainActor -@Test -func webSocketCreatedCreatesRequestWithConnectingState() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-created") - - await runtime.backend.emit( - .webSocket(.created(id: requestID, url: "wss://example.com/socket")), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - let request = try #require(results.items.first) - #expect(request.url == "wss://example.com/socket") - #expect(request.method == "GET") - #expect(request.resourceType == .webSocket) - #expect(request.state == .pending) - #expect(request.requestSentTimestamp == nil) - #expect(request.webSocket?.readyState == .connecting) - #expect(request.hasResponse == false) - #expect(request.hasResponseBody == false) - #expect(context.registeredRequest(for: request.id) === request) -} - -@MainActor -@Test -func webSocketCreatedPreservesExistingNetworkLifecycleMetadata() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-created-after-request") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request( - id: requestID, - url: "wss://example.com/socket", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - resourceType: .webSocket, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - status: 101, - statusText: "Switching Protocols", - headers: ["Upgrade": "websocket"], - requestHeaders: ["Upgrade": "websocket"] - ), - resourceType: .webSocket, - timestamp: 2 - ), - target: target - ) - await runtime.backend.emit( - .dataReceived(id: requestID, dataLength: 7, encodedDataLength: 3, timestamp: 3), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.first?.decodedDataLength == 7 } - let request = try #require(results.items.first) - let webSocket = try #require(request.webSocket) - - await runtime.backend.emit( - .webSocket(.created(id: requestID, url: "wss://example.com/socket?created")), - target: target - ) - try await waitUntil { request.url == "wss://example.com/socket?created" } - await runtime.backend.emit( - .webSocket(.handshakeRequest( - id: requestID, - request: Network.Request( - id: requestID, - url: "", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - timestamp: nil - )), - target: target - ) - await runtime.backend.emit( - .webSocket(.handshakeResponse( - id: requestID, - response: Network.Response( - status: 101, - statusText: "Switching Protocols", - headers: ["Upgrade": "websocket"], - requestHeaders: ["Upgrade": "websocket"] - ), - timestamp: nil - )), - target: target - ) - try await waitUntil { request.webSocket?.readyState == .open } - - let currentWebSocket = try #require(request.webSocket) - #expect(currentWebSocket === webSocket) - #expect(request.url == "wss://example.com/socket?created") - #expect(request.method == "GET") - #expect(request.requestHeaders["Upgrade"] == "websocket") - #expect(currentWebSocket.handshakeRequest?.url == "wss://example.com/socket?created") - #expect(request.status == 101) - #expect(request.responseHeaders["Upgrade"] == "websocket") - #expect(request.requestSentTimestamp == 1) - #expect(request.responseReceivedTimestamp == 2) - #expect(request.lastDataReceivedTimestamp == 3) - #expect(request.finishedOrFailedTimestamp == nil) - #expect(request.decodedDataLength == 7) - #expect(request.encodedDataLength == 3) - #expect(request.state == .responded) -} - -@MainActor -@Test -func webSocketLifecycleStoresHandshakeFramesErrorAndClosedState() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("websocket-lifecycle") - - await runtime.backend.emit( - .webSocket(.created(id: requestID, url: "wss://example.com/socket")), - target: target - ) - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - let request = try #require(results.items.first) - - await runtime.backend.emit( - .webSocket(.handshakeRequest( - id: requestID, - request: Network.Request( - id: requestID, - url: "wss://example.com/socket", - method: "GET", - headers: ["Upgrade": "websocket"] - ), - timestamp: 1 - )), - target: target - ) - try await waitUntil { - request.webSocket?.handshakeRequest?.headers["Upgrade"] == "websocket" - } - #expect(request.requestHeaders["Upgrade"] == "websocket") - #expect(request.requestSentTimestamp == 1) - #expect(request.webSocket?.readyState == .connecting) - #expect(request.state == .pending) - - await runtime.backend.emit( - .webSocket(.handshakeResponse( - id: requestID, - response: Network.Response( - status: 101, - statusText: "Switching Protocols", - headers: ["Upgrade": "websocket"], - requestHeaders: ["Upgrade": "websocket"] - ), - timestamp: 2 - )), - target: target - ) - try await waitUntil { - request.webSocket?.readyState == .open && request.state == .responded - } - #expect(request.webSocket?.handshakeResponse?.status == 101) - #expect(request.status == 101) - #expect(request.responseHeaders["Upgrade"] == "websocket") - #expect(request.requestHeaders["Upgrade"] == "websocket") - #expect(request.responseReceivedTimestamp == 2) - #expect(request.hasResponse) - #expect(request.hasResponseBody == false) - - await runtime.backend.emit( - .webSocket(.frameSent( - id: requestID, - frame: Network.WebSocketFrame(opcode: 1, mask: true, payloadData: "hello", payloadLength: 5), - timestamp: 3 - )), - target: target - ) - await runtime.backend.emit( - .webSocket(.frameReceived( - id: requestID, - frame: Network.WebSocketFrame(opcode: 1, mask: false, payloadData: "world", payloadLength: 5), - timestamp: 4 - )), - target: target - ) - await runtime.backend.emit( - .webSocket(.error(id: requestID, message: "boom", timestamp: 5)), - target: target - ) - try await waitUntil { request.webSocket?.frames.count == 3 } - let webSocket = try #require(request.webSocket) - #expect(webSocket.frames.map(\.direction) == [.sent, .received, .error("boom")]) - #expect(webSocket.frames[0].opcode == 1) - #expect(webSocket.frames[0].mask == true) - #expect(webSocket.frames[0].payloadData == "hello") - #expect(webSocket.frames[0].payloadLength == 5) - #expect(webSocket.frames[1].payloadData == "world") - #expect(webSocket.frames[2].errorMessage == "boom") - #expect(webSocket.frames.map(\.timestamp) == [3, 4, 5]) - #expect(request.decodedDataLength == 10) - #expect(request.lastDataReceivedTimestamp == 5) - - await runtime.backend.emit( - .webSocket(.closed(id: requestID, timestamp: 6)), - target: target - ) - try await waitUntil { - request.webSocket?.readyState == .closed && request.state == .finished - } - #expect(request.finishedOrFailedTimestamp == 6) -} - -@MainActor -@Test -func webSocketEventForUnknownRequestIsSkipped() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("missing-websocket") - - await runtime.backend.emit( - .webSocket(.handshakeResponse( - id: requestID, - response: Network.Response(status: 101), - timestamp: 1 - )), - target: target - ) - await runtime.backend.emit( - .webSocket(.created(id: requestID, url: "wss://example.com/socket")), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - #expect(results.items.first?.webSocket?.readyState == .connecting) - #expect(results.items.first?.webSocket?.handshakeResponse == nil) - #expect(context.state == .attached) -} - -@MainActor -@Test -func webSocketOtherEventDoesNotMutateRequests() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let results: WebInspectorFetchedResults = context.fetchedResults() - - let baseline = context.eventPumpAppliedSequenceForTesting - await runtime.backend.emit( - .webSocket(.other(RawEvent(domain: "Network", method: "webSocketFutureEvent"))), - target: target - ) - let didProcessOtherEvent = await context.waitForEventPumpAppliedSequenceForTesting(after: baseline) - #expect(didProcessOtherEvent) - - #expect(results.items.isEmpty) - #expect(context.state == .attached) -} - -@MainActor -@Test -func requestPostDataCreatesNetworkBodyWithFormRepresentation() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("form-request") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request( - id: requestID, - url: "https://example.com/form", - method: "POST", - headers: ["Content-Type": " application/x-www-form-urlencoded; charset=utf-8"], - postData: "name=Jane+Doe&city=Tokyo%20East" - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - let request = try #require(results.items.first) - let body = try #require(request.requestBody) - #expect(body.role == .request) - #expect(body.kind == .form) - #expect(body.phase == .loaded) - #expect(body.full == "name=Jane+Doe&city=Tokyo%20East") - #expect(body.text == "name=Jane+Doe&city=Tokyo%20East") - #expect(body.size == "name=Jane+Doe&city=Tokyo%20East".utf8.count) - #expect(body.isBase64Encoded == false) - #expect(body.isTruncated == false) - #expect(body.sourceSyntaxKind == .plainText) - #expect(body.textRepresentation == "name=Jane Doe\ncity=Tokyo East") - #expect(body.textRepresentationSyntaxKind == .plainText) - #expect(request.canFetchResponseBody == false) -} - -@MainActor -@Test -func responseRequestHeadersRefreshRequestBodyHints() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("form-request-hints") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request( - id: requestID, - url: "https://example.com/form", - method: "POST", - postData: "name=Jane+Doe&city=Tokyo%20East" - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.count == 1 } - let request = try #require(results.items.first) - let body = try #require(request.requestBody) - #expect(body.kind == .text) - #expect(body.textRepresentation == "name=Jane+Doe&city=Tokyo%20East") - - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response( - status: 200, - headers: ["Content-Type": "text/plain"], - requestHeaders: ["Content-Type": "application/x-www-form-urlencoded"] - ), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - - try await waitUntil { body.kind == .form } - #expect(request.requestHeaders["Content-Type"] == "application/x-www-form-urlencoded") - #expect(body.textRepresentation == "name=Jane Doe\ncity=Tokyo East") - #expect(body.textRepresentationSyntaxKind == .plainText) -} - -@MainActor -@Test -func responseBodyPublishesHintsAndFetchLifecycle() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("json-response-body") - - await runtime.backend.emit( - .requestWillBeSent( - id: requestID, - request: Network.Request( - id: requestID, - url: "https://example.com/api/data.json", - method: "GET" - ), - resourceType: .fetch, - redirectResponse: nil, - timestamp: 1 - ), - target: target - ) - await runtime.backend.emit( - .responseReceived( - id: requestID, - response: Network.Response(status: 200), - resourceType: .fetch, - timestamp: 2 - ), - target: target - ) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { results.items.first?.state == .responded } - let request = try #require(results.items.first) - let body = request.responseBody - #expect(body.role == .response) - #expect(body.kind == .text) - #expect(body.phase == .available) - #expect(body.full == nil) - #expect(body.sourceSyntaxKind == .json) - #expect(body.textRepresentation == nil) - #expect(body.textRepresentationSyntaxKind == .json) - #expect(request.canFetchResponseBody == false) - - await runtime.backend.emit( - .loadingFinished(id: requestID, timestamp: 3, sourceMapURL: nil, metrics: nil), - target: target - ) - try await waitUntil { request.canFetchResponseBody } - - await runtime.backend.enqueue( - Network.Body(data: #"{"ok":true}"#, base64Encoded: false), - for: "Network", - method: "getResponseBody" - ) - - await request.fetchResponseBody() - #expect(body.phase == .loaded) - #expect(body.full == #"{"ok":true}"#) - #expect(body.text == #"{"ok":true}"#) - #expect(body.size == #"{"ok":true}"#.utf8.count) - #expect(body.isBase64Encoded == false) - #expect(body.isTruncated == false) - #expect(body.textRepresentation == #"{"ok":true}"#) - #expect(body.textRepresentationSyntaxKind == .json) - #expect(request.canFetchResponseBody == false) - - let commandsBeforeSecondFetch = await runtime.backend.recordedCommands() - await request.fetchResponseBody() - let commandsAfterSecondFetch = await runtime.backend.recordedCommands() - #expect(commandsAfterSecondFetch == commandsBeforeSecondFetch) -} - -@MainActor -@Test -func fetchResponseBodyStoresLoadedAndFailedPhases() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let loadedID = Network.Request.ID("loaded-request") - let failedID = Network.Request.ID("failed-request") - - await emitFinishedRequest(id: loadedID, target: target, backend: runtime.backend) - await emitFinishedRequest(id: failedID, target: target, backend: runtime.backend) - - let results: WebInspectorFetchedResults = context.fetchedResults() - try await waitUntil { - results.items.count == 2 && results.items.allSatisfy { $0.state == .finished } - } - let loadedRequest = try #require(results.items.first { $0.id == NetworkRequest.ID(loadedID) }) - let failedRequest = try #require(results.items.first { $0.id == NetworkRequest.ID(failedID) }) - - await runtime.backend.enqueue( - Network.Body(data: "hello", base64Encoded: false), - for: "Network", - method: "getResponseBody" - ) - - await loadedRequest.fetchResponseBody() - #expect(loadedRequest.responseBody.phase == .loaded) - #expect(loadedRequest.responseBody.text == "hello") - #expect(loadedRequest.responseBody.isBase64Encoded == false) - - await failedRequest.fetchResponseBody() - guard case let .failed(error) = failedRequest.responseBody.phase else { - Issue.record("Expected failed response body phase.") - return - } - guard case .commandFailed(domain: "Network", method: "getResponseBody", message: _) = error else { - Issue.record("Expected Network.getResponseBody command failure.") - return - } - - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Network", method: "getResponseBody"))) -} - -@MainActor -@Test -func fetchResponseBodyDropsCompletionAfterNetworkClear() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("cleared-body-request") - let gate = WebInspectorTestGate() - - await emitFinishedRequest(id: requestID, target: target, backend: runtime.backend) - try await waitUntil { - context.registeredRequest(for: NetworkRequest.ID(requestID))?.state == .finished - } - let request = try #require(context.registeredRequest(for: NetworkRequest.ID(requestID))) - let body = request.responseBody - await runtime.backend.hold(domain: "Network", method: "getResponseBody", gate: gate) - - let fetchTask = Task { - await request.fetchResponseBody() - } - try await waitUntil { - await runtime.backend.recordedCommands().contains(RecordedCommand(domain: "Network", method: "getResponseBody")) - } - - context.clearNetworkRequests() - #expect(context.registeredRequest(for: NetworkRequest.ID(requestID)) == nil) - await runtime.backend.enqueue( - Network.Body(data: "stale-body", base64Encoded: false), - for: "Network", - method: "getResponseBody" - ) - await gate.open() - await fetchTask.value - - #expect(body.phase == NetworkBody.Phase.fetching) - #expect(body.text == nil) - #expect(request.responseBody === body) -} - -@MainActor -@Test -func consoleEventsPopulateRepeatAndClearFetchedMessages() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let requestID = Network.Request.ID("request-1") - - let results: WebInspectorFetchedResults = context.fetchedResults() - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - type: Console.Kind(rawValue: "log"), - text: "hello", - url: "https://example.com/app.js", - line: 12, - column: 4, - repeatCount: 1, - networkRequestID: requestID, - timestamp: 1 - )), - target: target - ) - - try await waitUntil { results.items.count == 1 } - let message = try #require(results.items.first) - #expect(message.source == Console.Source(rawValue: "console-api")) - #expect(message.level == Console.Level(rawValue: "warning")) - #expect(message.kind == Console.Kind(rawValue: "log")) - #expect(message.text == "hello") - #expect(message.url == "https://example.com/app.js") - #expect(message.line == 12) - #expect(message.column == 4) - #expect(message.repeatCount == 1) - #expect(message.networkRequestID == NetworkRequest.ID(requestID)) - #expect(message.timestamp == 1) - #expect(context.registeredMessage(for: message.id) === message) - - await runtime.backend.emit( - .messageRepeatCountUpdated(count: 3, timestamp: 2), - target: target - ) - try await waitUntil { message.repeatCount == 3 } - #expect(results.items.first === message) - #expect(message.timestamp == 2) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "error"), - text: "second" - )), - target: target - ) - try await waitUntil { results.items.count == 2 } - #expect(results.items.map(\.text) == ["hello", "second"]) - - await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") - await runtime.backend.emit( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - target: target - ) - try await waitUntil { results.items.isEmpty } - #expect(context.registeredMessage(for: message.id) == nil) - try await waitUntil { - await runtime.backend.recordedCommands() - .contains(RecordedCommand(domain: "Runtime", method: "releaseObjectGroup")) - } -} - -@MainActor -@Test -func consoleFetchedResultsHonorDescriptorsForInitialUpdatesAndDescriptorChanges() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let allResults: WebInspectorFetchedResults = context.fetchedResults() - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "middle" - )), - target: target - ) - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "javascript"), - level: Console.Level(rawValue: "error"), - text: "zeta" - )), - target: target - ) - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "omega" - )), - target: target - ) - try await waitUntil { allResults.items.count == 3 } - - let warningDescriptor = WebInspectorFetchDescriptor( - predicate: #Predicate { message in - message.level.rawValue == "warning" - }, - sortBy: [SortDescriptor(\.text, order: .reverse)], - fetchLimit: 2 - ) - let warningResults: WebInspectorFetchedResults = context.fetchedResults(for: warningDescriptor) - - #expect(warningResults.items.map(\.text) == ["omega", "middle"]) - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "warning"), - text: "zebra" - )), - target: target - ) - try await waitUntil { - warningResults.items.map(\.text) == ["zebra", "omega"] - } - - warningResults.updateFetchDescriptor(WebInspectorFetchDescriptor( - sortBy: [SortDescriptor(\.text)], - fetchLimit: 2, - fetchOffset: 1 - )) - - try await waitUntil { - warningResults.items.map(\.text) == ["omega", "zebra"] - } -} - -@MainActor -@Test -func consoleMessageParametersRegisterRuntimeObjects() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let objectID = Runtime.RemoteObject.ID("console-object") - let results: WebInspectorFetchedResults = context.fetchedResults() - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "first", - parameters: [ - Runtime.RemoteObject(id: objectID, kind: .object, description: "before") - ] - )), - target: target - ) - try await waitUntil { results.items.count == 1 } - let firstMessage = try #require(results.items.first) - let firstParameter = try #require(firstMessage.parameters.first) - #expect(firstParameter.description == "before") - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "second", - parameters: [ - Runtime.RemoteObject(id: objectID, kind: .object, description: "after") - ] - )), - target: target - ) - try await waitUntil { results.items.count == 2 } - let secondMessage = try #require(results.items.last) - let secondParameter = try #require(secondMessage.parameters.first) - - #expect(firstParameter === secondParameter) - #expect(firstParameter.description == "after") -} - -@MainActor -@Test -func consoleMessagesClearedReleasesConsoleRuntimeObjects() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let objectID = Runtime.RemoteObject.ID("console-stale-object") - let results: WebInspectorFetchedResults = context.fetchedResults() - - await runtime.backend.emit( - .messageAdded(Console.Message( - source: Console.Source(rawValue: "console-api"), - level: Console.Level(rawValue: "log"), - text: "clear me", - parameters: [ - Runtime.RemoteObject(id: objectID, kind: .object, description: "console object") - ] - )), - target: target - ) - try await waitUntil { results.items.count == 1 } - let message = try #require(results.items.first) - let parameter = try #require(message.parameters.first) - - await runtime.backend.enqueue((), for: "Runtime", method: "releaseObjectGroup") - await runtime.backend.emit( - .messagesCleared(reason: Console.ClearReason(rawValue: "console-api")), - target: target - ) - - try await waitUntil { - await runtime.backend.recordedCommands() - .contains(RecordedCommand(domain: "Runtime", method: "releaseObjectGroup")) - } - try await waitUntil { results.items.isEmpty } - do { - _ = try await parameter.properties() - Issue.record("Expected cleared console RuntimeObject to be stale.") - } catch let error as WebInspectorProxyError { - #expect(error == .disconnected("RuntimeObject is not registered in this WebInspectorContext.")) - } - #expect(context.state == .attached) -} - -@MainActor -@Test -func evaluateRegistersRuntimeObjectInSelectedContext() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("main") - let objectID = Runtime.RemoteObject.ID("evaluation-result") - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Main", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - let runtimeContext = try #require(context.executionContexts.first) - context.selectContext(runtimeContext) - - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject( - id: objectID, - kind: .string, - description: "hello", - value: .string("hello") - ), - wasThrown: true, - savedResultIndex: 7 - ), - for: "Runtime", - method: "evaluate" - ) - - let result = try await context.evaluate("throw 'hello'", in: runtimeContext) - #expect(result.isException) - #expect(result.object.kind == .string) - #expect(result.object.value == .string("hello")) - #expect(result.object.description == "hello") - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.last { $0.domain == "Runtime" && $0.method == "evaluate" }) - let payload = try #require(command.payload.cast(as: Runtime.EvaluatePayload.self)) - #expect(payload.expression == "throw 'hello'") - #expect(payload.context == contextID) -} - -@MainActor -@Test -func runtimeObjectPropertiesAndCollectionEntriesUseRuntimeCommands() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (_, context) = try await startContext(runtime: runtime) - let objectID = Runtime.RemoteObject.ID("root-object") - let childID = Runtime.RemoteObject.ID("child-object") - let entryValueID = Runtime.RemoteObject.ID("entry-value") - - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject(id: objectID, kind: .object, description: "root") - ), - for: "Runtime", - method: "evaluate" - ) - let evaluation = try await context.evaluate("window") - - await runtime.backend.enqueue( - [ - Runtime.PropertyDescriptor( - name: "answer", - value: Runtime.RemoteObject(id: nil, kind: .number, description: "42", value: .number(42)) - ), - Runtime.PropertyDescriptor( - name: "child", - value: Runtime.RemoteObject(id: childID, kind: .object, description: "child") - ), - ], - for: "Runtime", - method: "getProperties" - ) - - let properties = try await evaluation.object.properties() - #expect(properties.count == 2) - #expect(properties[0].name == "answer") - #expect(properties[0].value == "42") - #expect(properties[0].object == nil) - let child = try #require(properties[1].object) - #expect(child.description == "child") - - await runtime.backend.enqueue( - [ - Runtime.CollectionEntry( - key: Runtime.RemoteObject(id: nil, kind: .string, description: "key", value: .string("key")), - value: Runtime.RemoteObject(id: entryValueID, kind: .object, description: "entry value") - ) - ], - for: "Runtime", - method: "getCollectionEntries" - ) - - let entries = try await evaluation.object.collectionEntries() - #expect(entries.count == 1) - #expect(entries[0].key?.value == .string("key")) - #expect(entries[0].value?.description == "entry value") - - let commands = await runtime.backend.recordedCommands() - #expect(commands.contains(RecordedCommand(domain: "Runtime", method: "getProperties"))) - #expect(commands.contains(RecordedCommand(domain: "Runtime", method: "getCollectionEntries"))) -} - -@MainActor -@Test -func staleRuntimeObjectThrowsWithoutFailingContext() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let contextID = Runtime.ExecutionContext.ID("main") - let objectID = Runtime.RemoteObject.ID("stale-object") - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: contextID, - name: "Main", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - - await runtime.backend.enqueue( - Runtime.EvaluationResult( - object: Runtime.RemoteObject(id: objectID, kind: .object, description: "stale") - ), - for: "Runtime", - method: "evaluate" - ) - let evaluation = try await context.evaluate("window") - - await runtime.backend.emit(.executionContextsCleared(target: target.id), target: target) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil +private func enqueueStartupReplies( + on wire: DataKitRawWireDriver, + configuration: WebInspectorModelContext.Configuration, + document: DOM.Node +) async { + if configuration.domains.contains(.css) { + await wire.respond(to: "Page.enable") + await wire.respond(to: "CSS.enable") } - - do { - _ = try await evaluation.object.properties() - Issue.record("Expected stale runtime object to throw.") - } catch let error as WebInspectorProxyError { - #expect(error == .disconnected("RuntimeObject is not registered in this WebInspectorContext.")) + if configuration.domains.contains(.network) { + await wire.respond(to: "Network.enable") } - #expect(context.state == .attached) -} - -@MainActor -@Test -func runtimeEventsPopulateContextsAndFallbackSelection() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let (target, context) = try await startContext(runtime: runtime) - let mainID = Runtime.ExecutionContext.ID("main") - let utilityID = Runtime.ExecutionContext.ID("utility") - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: mainID, - name: "Main", - kind: .normal - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 1 } - let mainContext = try #require(context.executionContexts.first) - #expect(context.selectedContext === mainContext) - #expect(mainContext.name == "Main") - #expect(mainContext.kind == .normal) - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: mainID, - name: "Main Updated", - kind: .normal - )), - target: target - ) - try await waitUntil { mainContext.name == "Main Updated" } - #expect(context.executionContexts.first === mainContext) - - await runtime.backend.emit( - .executionContextCreated(Runtime.ExecutionContext( - id: utilityID, - name: "Utility", - kind: .user - )), - target: target - ) - try await waitUntil { context.executionContexts.count == 2 } - let utilityContext = try #require(context.executionContexts.first { $0.id == RuntimeContext.ID(utilityID) }) - #expect(context.selectedContext === mainContext) - - context.selectContext(utilityContext) - #expect(context.selectedContext === utilityContext) - - await runtime.backend.emit( - .executionContextDestroyed(utilityID), - target: target - ) - try await waitUntil { - context.executionContexts.count == 1 && context.selectedContext === mainContext + if configuration.domains.contains(.console) { + await wire.respond(to: "Console.enable") } - #expect(context.executionContexts.first === mainContext) - - await runtime.backend.emit( - .executionContextsCleared(target: target.id), - target: target - ) - try await waitUntil { - context.executionContexts.isEmpty && context.selectedContext == nil + if configuration.domains.contains(.runtime) { + await wire.respond(to: "Runtime.enable") + } + if configuration.domains.contains(.dom) { + await wire.respond( + to: "DOM.getDocument", + with: try! domDocumentResult(document) + ) } } -@MainActor -private func startContext( - runtime: WebInspectorProxyTestRuntime, - document: DOM.Node = DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") -) async throws -> (WebInspectorTarget, WebInspectorContext) { - let target = try await runtime.proxy.waitForCurrentPage() - await enqueueStartupReplies(on: runtime.backend, document: document) - - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForStartupSubscribers(runtime: runtime, target: target) - try await waitUntil { context.state == .attached } - return (target, context) -} - -private var startupCommands: [RecordedCommand] { - [ - RecordedCommand(domain: "Inspector", method: "enable"), - RecordedCommand(domain: "Inspector", method: "initialized"), - RecordedCommand(domain: "Runtime", method: "enable"), - RecordedCommand(domain: "Network", method: "enable"), - RecordedCommand(domain: "DOM", method: "getDocument"), - RecordedCommand(domain: "Console", method: "enable"), - ] -} - -private var shutdownCommands: [RecordedCommand] { - [ - RecordedCommand(domain: "Console", method: "disable"), - RecordedCommand(domain: "Runtime", method: "disable"), - RecordedCommand(domain: "Network", method: "disable"), - RecordedCommand(domain: "Inspector", method: "disable"), - ] -} - -private func enqueueStartupReplies( - on backend: WebInspectorTestBackend, - document: DOM.Node = DOM.Node(id: DOM.Node.ID("document"), nodeType: 9, nodeName: "#document") +private func enqueueShutdownReplies( + on wire: DataKitRawWireDriver, + configuration: WebInspectorModelContext.Configuration ) async { - await enqueueDomainEnableReplies(on: backend) - await backend.enqueue(document, for: "DOM", method: "getDocument") -} - -private func enqueueDomainEnableReplies(on backend: WebInspectorTestBackend) async { - await backend.enqueue((), for: "Inspector", method: "enable") - await backend.enqueue((), for: "Inspector", method: "initialized") - await backend.enqueue((), for: "Runtime", method: "enable") - await backend.enqueue((), for: "Network", method: "enable") - await backend.enqueue((), for: "Console", method: "enable") -} - -private func enqueueDomainDisableReplies(on backend: WebInspectorTestBackend) async { - await backend.enqueue((), for: "Console", method: "disable") - await backend.enqueue((), for: "Runtime", method: "disable") - await backend.enqueue((), for: "Network", method: "disable") - await backend.enqueue((), for: "Inspector", method: "disable") -} - -private func enqueueCSSStyleReplies(on backend: WebInspectorTestBackend) async { - await backend.enqueue( - CSS.MatchedStyles(matchedRules: [ - CSS.Rule( - selectorList: CSS.Rule.SelectorList(selectors: [".card"], text: ".card"), - origin: CSS.Origin(rawValue: "regular"), - style: CSS.Style( - id: CSS.Style.ID("style-1"), - properties: [ - CSS.Property( - id: CSS.Property.ID("property-1"), - name: "display", - value: "grid", - text: "display: grid;", - isEditable: true - ) - ], - cssText: "display: grid;", - isEditable: true - ) - ) - ]), - for: "CSS", - method: "getMatchedStylesForNode" - ) - await backend.enqueue( - CSS.InlineStyles(), - for: "CSS", - method: "getInlineStylesForNode" - ) - await backend.enqueue( - [ - CSS.ComputedProperty(name: "display", value: "grid") - ], - for: "CSS", - method: "getComputedStyleForNode" - ) -} - -private func matchedStylesCommandCount(on backend: WebInspectorTestBackend) async -> Int { - await backend.recordedCommands() - .filter { $0 == RecordedCommand(domain: "CSS", method: "getMatchedStylesForNode") } - .count -} - -@MainActor -private func waitForStartupSubscribers( - runtime: WebInspectorProxyTestRuntime, - target: WebInspectorTarget -) async throws { - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "CSS", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Network", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Console", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Runtime", target: target, count: 1) + if configuration.domains.contains(.runtime) { + await wire.respond(to: "Runtime.disable") + } + if configuration.domains.contains(.console) { + await wire.respond(to: "Console.disable") + } + if configuration.domains.contains(.network) { + await wire.respond(to: "Network.disable") + } + if configuration.domains.contains(.css) { + await wire.respond(to: "CSS.disable") + await wire.respond(to: "Page.disable") + } } private func emitFinishedRequest( id: Network.Request.ID, target: WebInspectorTarget, - backend: WebInspectorTestBackend -) async { - await backend.emit( + wire: DataKitRawWireDriver +) async throws { + try await wire.emitRaw( .requestWillBeSent( id: id, - request: Network.Request(id: id, url: "https://example.com/\(id)", method: "GET"), + request: Network.Request( + id: id, + url: "https://example.com/\(id.rawValue)", + method: "GET" + ), resourceType: .fetch, redirectResponse: nil, timestamp: 1 ), target: target ) - await backend.emit( + try await wire.emitRaw( .responseReceived( id: id, - response: Network.Response(status: 200, mimeType: "text/plain"), + response: Network.Response( + url: "https://example.com/\(id.rawValue)", + status: 200, + mimeType: "text/plain" + ), resourceType: .fetch, timestamp: 2 ), target: target ) - await backend.emit(.loadingFinished(id: id, timestamp: 3, sourceMapURL: nil, metrics: nil), target: target) -} - -@MainActor -private func startTransportBackedContext( - targetID: ProtocolTarget.ID, - documentID: String -) async throws -> (FakeTransportBackend, TransportSession, WebInspectorContext) { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installTransportPageTarget(in: transport, targetID: targetID) - let proxy = try await WebInspectorProxy(transport: transport) - let container = WebInspectorContainer(proxy: proxy) - let context = container.mainContext - - try await replyTransportInspectorInitialization(backend, transport: transport, targetID: targetID) - - let runtimeEnable = try await waitForTransportTargetMessage(backend, method: "Runtime.enable") - #expect(runtimeEnable.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: runtimeEnable.targetIdentifier, - messageID: try transportMessageID(runtimeEnable.message), - result: "{}" - ) - - let networkEnable = try await waitForTransportTargetMessage(backend, method: "Network.enable") - #expect(networkEnable.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: networkEnable.targetIdentifier, - messageID: try transportMessageID(networkEnable.message), - result: "{}" - ) - - let getDocument = try await waitForTransportTargetMessage(backend, method: "DOM.getDocument") - #expect(getDocument.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: getDocument.targetIdentifier, - messageID: try transportMessageID(getDocument.message), - result: transportDocumentResult(nodeID: documentID) - ) - - let consoleEnable = try await waitForTransportTargetMessage(backend, method: "Console.enable") - #expect(consoleEnable.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: consoleEnable.targetIdentifier, - messageID: try transportMessageID(consoleEnable.message), - result: "{}" - ) - - try await waitUntil { context.state == .attached } - return (backend, transport, context) -} - -@discardableResult -private func replyTransportInspectorInitialization( - _ backend: FakeTransportBackend, - transport: TransportSession, - targetID: ProtocolTarget.ID, - after count: Int = 0, - timeout: Duration = .seconds(1) -) async throws -> (enable: SentTargetMessage, initialized: SentTargetMessage) { - let inspectorEnable = try await waitForTransportTargetMessage( - backend, - method: "Inspector.enable", - after: count, - timeout: timeout - ) - #expect(inspectorEnable.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: inspectorEnable.targetIdentifier, - messageID: try transportMessageID(inspectorEnable.message), - result: "{}" - ) - - let inspectorInitialized = try await waitForTransportTargetMessage( - backend, - method: "Inspector.initialized", - after: count, - timeout: timeout - ) - #expect(inspectorInitialized.targetIdentifier == targetID) - await receiveTransportTargetReply( - transport, - targetID: inspectorInitialized.targetIdentifier, - messageID: try transportMessageID(inspectorInitialized.message), - result: "{}" - ) - - return (enable: inspectorEnable, initialized: inspectorInitialized) -} - -private func installTransportPageTarget( - in transport: TransportSession, - targetID: ProtocolTarget.ID, - frameID: String = "main-frame" -) async { - let targetID = jsonEscapedString(targetID.rawValue) - let frameID = jsonEscapedString(frameID) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"\#(targetID)","type":"page","frameId":"\#(frameID)","isProvisional":false}}}"# + try await wire.emitRaw( + .loadingFinished( + id: id, + timestamp: 3, + sourceMapURL: nil, + metrics: nil + ), + target: target ) } @MainActor -private func startAutoReplyingTransportTargetMessages( - backend: FakeTransportBackend, - transport: TransportSession, - documentNodeID: @escaping @MainActor @Sendable (ProtocolTarget.ID) -> String -) -> Task { - Task { @MainActor in - var repliedTargetMessageCount = 0 - while Task.isCancelled == false { - do { - let sentMessage = try await backend.waitForTargetMessage(after: repliedTargetMessageCount) - repliedTargetMessageCount += 1 - - let messageID = try transportMessageID(sentMessage.message) - let method = try transportTargetMessageMethod(sentMessage.message) - let result = method == "DOM.getDocument" - ? transportDocumentResult(nodeID: documentNodeID(sentMessage.targetIdentifier)) - : "{}" - - await receiveTransportTargetReply( - transport, - targetID: sentMessage.targetIdentifier, - messageID: messageID, - result: result - ) - } catch is CancellationError { - return - } catch { - Issue.record("Failed to auto-reply to transport target message: \(error)") - return - } - } - } -} - -private func waitForTransportTargetMessage( - _ backend: FakeTransportBackend, - method: String, - ordinal: Int = 0, - after count: Int = 0, - timeout: Duration = .seconds(1) -) async throws -> SentTargetMessage { - try await withThrowingTaskGroup(of: SentTargetMessage.self) { group in - group.addTask { - try await backend.waitForTargetMessage(method: method, ordinal: ordinal, after: count) - } - group.addTask { - try await Task.sleep(for: timeout) - let sentMethods = await backend.sentTargetMessages().enumerated().map { index, message in - let method = (try? transportTargetMessageMethod(message.message)) ?? "" - return "#\(index):\(method)@\(message.targetIdentifier.rawValue)" - }.joined(separator: ", ") - throw TimedOut( - "Timed out waiting for \(method) ordinal \(ordinal) after \(count) within \(timeout). " - + "Sent target messages: [\(sentMethods)]" - ) - } - guard let message = try await group.next() else { - throw TimedOut() - } - group.cancelAll() - return message - } -} - -private func waitForTransportTargetMessageReplyingToInterleavedGetDocuments( - _ backend: FakeTransportBackend, - transport: TransportSession, - targetID: ProtocolTarget.ID, - method expectedMethod: String, - after count: Int, - documentNodeID: String, - repliedGetDocumentMessageIDs: Set, - timeout: Duration = .seconds(1) -) async throws -> SentTargetMessage { - let clock = ContinuousClock() - let deadline = clock.now + timeout - var repliedGetDocumentMessageIDs = repliedGetDocumentMessageIDs - - while true { - let sentMessages = await backend.sentTargetMessages() - for sentMessage in sentMessages.dropFirst(count) where sentMessage.targetIdentifier == targetID { - let method = try transportTargetMessageMethod(sentMessage.message) - if method == expectedMethod { - return sentMessage - } - guard method == "DOM.getDocument" else { - continue - } - - let messageID = try transportMessageID(sentMessage.message) - guard repliedGetDocumentMessageIDs.insert(messageID).inserted else { - continue - } - await receiveTransportTargetReply( - transport, - targetID: sentMessage.targetIdentifier, - messageID: messageID, - result: transportDocumentResult(nodeID: documentNodeID) - ) - } - - if clock.now >= deadline { - let sentMethods = try sentMessages.enumerated().map { index, message in - "#\(index):\(try transportTargetMessageMethod(message.message))@\(message.targetIdentifier.rawValue)" - }.joined(separator: ", ") - throw TimedOut( - "Timed out waiting for \(expectedMethod) after \(count) within \(timeout). " - + "Sent target messages: [\(sentMethods)]" - ) - } - - await Task.yield() - } -} - -private func receiveTransportTargetReply( - _ transport: TransportSession, - targetID: ProtocolTarget.ID, - messageID: UInt64, - result: String -) async { - await transport.receiveRootMessage(transportTargetDispatchMessage( - targetID: targetID, - message: #"{"id":\#(messageID),"result":\#(result)}"# - )) -} - -private func receiveTransportTargetEvent( - _ transport: TransportSession, - targetID: ProtocolTarget.ID, - method: String, - params: String -) async { - await transport.receiveRootMessage(transportTargetDispatchMessage( - targetID: targetID, - message: #"{"method":"\#(method)","params":\#(params)}"# - )) -} - -private func transportDocumentResult(nodeID: String) -> String { - let escapedNodeID = jsonEscapedString(nodeID) - return ##"{"root":{"nodeId":"\##(escapedNodeID)","nodeType":9,"nodeName":"#document","localName":"","nodeValue":"","frameId":"main-frame","childNodeCount":0}}"## -} - -private func transportTargetDispatchMessage(targetID: ProtocolTarget.ID, message: String) -> String { - let escapedTargetID = jsonEscapedString(targetID.rawValue) - let escapedMessage = jsonEscapedString(message) - return #"{"method":"Target.dispatchMessageFromTarget","params":{"targetId":"\#(escapedTargetID)","message":"\#(escapedMessage)"}}"# -} - -private func jsonEscapedString(_ string: String) -> String { - string - .replacingOccurrences(of: #"\"#, with: #"\\"#) - .replacingOccurrences(of: #"""#, with: #"\""#) -} - -private func transportMessageID(_ message: String) throws -> UInt64 { - let object = try transportMessageObject(message) - if let number = object["id"] as? NSNumber { - return number.uint64Value - } - if let string = object["id"] as? String, - let id = UInt64(string) { - return id - } - throw TransportSession.Error.malformedMessage -} - -private func transportTargetMessageMethod(_ message: String) throws -> String { - let object = try transportMessageObject(message) - guard let method = object["method"] as? String else { - throw TransportSession.Error.malformedMessage - } - return method -} - -private func transportTargetMessageParameters(_ message: String) throws -> [String: Any] { - let object = try transportMessageObject(message) - return try #require(object["params"] as? [String: Any]) -} - -private func transportMessageObject(_ message: String) throws -> [String: Any] { - let data = try #require(message.data(using: .utf8)) - return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) -} - -@MainActor -private final class DOMTreeUpdateRecorder { - private(set) var updates: [DOMTreeUpdate] = [] - - private var task: Task? - private var hasStarted = false - - init(stream: AsyncStream) { - task = Task { @MainActor [weak self] in - self?.hasStarted = true - for await update in stream { - self?.updates.append(update) - } - } - } - - func waitUntilStarted() async throws { - try await waitUntil { self.hasStarted } - } - - func waitForUpdateCount(_ count: Int) async throws { - try await waitUntil { self.updates.count >= count } - } - - func cancel() { - task?.cancel() - task = nil - } - - deinit { - task?.cancel() - } -} - -@MainActor -private final class DOMTreeRevealRequestRecorder { - private(set) var requests: [DOMTreeRevealRequest] = [] - - private var task: Task? - private var hasStarted = false - - init(stream: AsyncStream) { - task = Task { @MainActor [weak self] in - self?.hasStarted = true - for await request in stream { - self?.requests.append(request) - } - } - } - - func waitUntilStarted() async throws { - try await waitUntil { self.hasStarted } - } - - func waitForRequestCount(_ count: Int) async throws { - try await waitUntil { self.requests.count >= count } - } - - func cancel() { - task?.cancel() - task = nil - } - - deinit { - task?.cancel() - } -} - -@MainActor -private final class FetchedResultsTransactionRecorder { - private(set) var transactions: [WebInspectorFetchedResultsTransaction] = [] - - private var task: Task? - private var hasStarted = false - - init(stream: AsyncStream>) { - task = Task { @MainActor [weak self] in - self?.hasStarted = true - for await transaction in stream { - self?.transactions.append(transaction) - } - } - } - - func waitUntilStarted() async throws { - try await waitUntil { self.hasStarted } - } - - func waitForTransactionCount(_ count: Int) async throws { - try await waitUntil { self.transactions.count >= count } - } - - func cancel() { - task?.cancel() - task = nil - } - - deinit { - task?.cancel() - } -} - -@MainActor -private func waitForChild(in context: WebInspectorContext) async throws -> DOMNode { +private func requireRequest( + _ id: Network.Request.ID, + in context: WebInspectorModelContext +) async throws -> NetworkRequest { try await waitUntil { - guard let root = context.rootNode else { - return false - } - guard case let .loaded(children) = root.children else { - return false - } - return children.isEmpty == false - } - - let root = try #require(context.rootNode) - guard case let .loaded(children) = root.children else { - Issue.record("Expected loaded root children.") - throw TestFailure() - } - return try #require(children.first) -} - -private struct TestFailure: Error {} -private struct TimedOut: Error, CustomStringConvertible { - var description: String - - init(_ description: String = "Timed out") { - self.description = description - } -} - -private final class CancellationProbe: @unchecked Sendable { - private let lock = NSLock() - private var hasStarted = false - private var isCancelled = false - - func markStarted() { - lock.lock() - defer { - lock.unlock() - } - hasStarted = true - } - - func markCancelled() { - lock.lock() - defer { - lock.unlock() - } - isCancelled = true - } - - func started() -> Bool { - lock.lock() - defer { - lock.unlock() - } - return hasStarted - } - - func cancelled() -> Bool { - lock.lock() - defer { - lock.unlock() - } - return isCancelled + try context.networkRequest(id: NetworkRequest.ID(id))?.state == .finished } + return try #require( + try context.networkRequest(id: NetworkRequest.ID(id)) + ) } @MainActor private func waitUntil( - timeout: Duration = .seconds(1), - condition: @escaping @MainActor @Sendable () -> Bool + _ condition: @MainActor () throws -> Bool ) async throws { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while condition() == false { - if clock.now >= deadline { - throw TimedOut() + for _ in 0..<2_000 { + if try condition() { + return } await Task.yield() } + throw ModelContextTestFailure.timedOut } -@MainActor -private func waitUntil( - timeout: Duration = .seconds(1), - condition: @escaping @MainActor @Sendable () async -> Bool -) async throws { - let clock = ContinuousClock() - let deadline = clock.now + timeout - while await condition() == false { - if clock.now >= deadline { - throw TimedOut() +private enum ModelContextTestFailure: Error { + case operation + case timedOut +} + +private extension Array where Element == String { + func containsSubsequence(_ subsequence: [String]) -> Bool { + var index = subsequence.startIndex + for element in self where index < subsequence.endIndex { + if element == subsequence[index] { + subsequence.formIndex(after: &index) + } } - await Task.yield() + return index == subsequence.endIndex } } diff --git a/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift new file mode 100644 index 00000000..0ff6c6a3 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/ConnectionModelFeedTests.swift @@ -0,0 +1,4032 @@ +import Dispatch +import Foundation +import Synchronization +import Testing +import WebInspectorTestSupport +@testable import WebInspectorProxyKit + +@Test +func modelTargetSnapshotOrdersMainAndCommittedFramesDeterministically() throws { + var registry = TransportTargetRegistry() + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "page-main", + kind: .page, + frameID: "main-frame" + )) + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "frame-b", + kind: .frame, + frameID: "frame-b", + parentFrameID: "main-frame" + )) + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "frame-c", + kind: .frame, + frameID: "frame-c", + parentFrameID: "frame-a" + )) + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "frame-a", + kind: .frame, + frameID: "frame-a", + parentFrameID: "main-frame" + )) + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "frame-parentless", + kind: .frame, + frameID: "frame-parentless" + )) + _ = registry.recordTargetCreated(modelFeedTargetRecord( + id: "frame-provisional", + kind: .frame, + frameID: "frame-provisional", + parentFrameID: "main-frame", + isProvisional: true + )) + + let snapshot = try #require(registry.modelTargetSnapshot()) + + #expect(snapshot.currentPageID == WebInspectorTarget.ID("page-main")) + #expect(snapshot.targets.map(\.id) == [ + WebInspectorTarget.ID("page-main"), + WebInspectorTarget.ID("frame-a"), + WebInspectorTarget.ID("frame-b"), + WebInspectorTarget.ID("frame-parentless"), + WebInspectorTarget.ID("frame-c"), + ]) +} + +@Test +func modelDomainNormalizationAddsDOMOnlyWhenCSSRequiresIt() { + #expect(ModelDomain.normalized([.css]) == [.dom, .css]) + #expect(ModelDomain.normalized([.network]) == [.network]) + #expect(ModelDomain.normalized([.dom, .runtime]) == [.dom, .runtime]) +} + +@Test +func directElementPickerScopesShareOnePhysicalModeAndInitializeOncePerGeneration() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let firstScopeTask = Task { + try await modelFeedAcquireDirectElementPickerScope(core: core) + } + try await modelFeedCompleteElementPickerAcquisition( + core: core, + backend: backend, + targetID: "page-main", + expectsInitialization: true + ) + let firstScope = try await firstScopeTask.value + + let acquiredMessageCount = await backend.sentTargetMessages().count + let secondScope = try await modelFeedAcquireDirectElementPickerScope(core: core) + #expect(await backend.sentTargetMessages().count == acquiredMessageCount) + + try await core.releaseEventScope(firstScope.id) + #expect(await backend.sentTargetMessages().count == acquiredMessageCount) + + let secondRelease = Task { + try await core.releaseEventScope(secondScope.id) + } + try await modelFeedCompleteElementPickerRelease( + core: core, + backend: backend, + targetID: "page-main", + after: acquiredMessageCount + ) + try await secondRelease.value + + let reacquireBaseline = await backend.sentTargetMessages().count + let reacquireTask = Task { + try await modelFeedAcquireDirectElementPickerScope(core: core) + } + try await modelFeedCompleteElementPickerAcquisition( + core: core, + backend: backend, + targetID: "page-main", + after: reacquireBaseline, + expectsInitialization: false + ) + let reacquiredScope = try await reacquireTask.value + let reacquireMethods = try await backend.sentTargetMessages() + .dropFirst(reacquireBaseline) + .map { try modelFeedMessageMethod($0.message) } + #expect(reacquireMethods == [ + "Inspector.enable", + "DOM.setInspectModeEnabled", + ]) + + let finalRelease = Task { + try await core.releaseEventScope(reacquiredScope.id) + } + try await modelFeedCompleteElementPickerRelease( + core: core, + backend: backend, + targetID: "page-main", + after: await backend.sentTargetMessages().count + ) + try await finalRelease.value + await core.close() +} + +@Test +func directElementPickerDiscardsInspectReceivedBeforeModeActivationReply() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let scopeTask = Task { + try await modelFeedAcquireDirectElementPickerScope(core: core) + } + let enable = try await backend.waitForTargetMessage(method: "Inspector.enable") + await modelFeedRespond(to: enable, core: core) + let initialized = try await backend.waitForTargetMessage(method: "Inspector.initialized") + await modelFeedRespond(to: initialized, core: core) + let activate = try await backend.waitForTargetMessage(method: "DOM.setInspectModeEnabled") + #expect(try modelFeedElementPickerEnabled(activate.message) == true) + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: modelFeedInspectorInspectMessage(objectID: "before-activation") + )) + await modelFeedRespond(to: activate, core: core) + let scope = try await scopeTask.value + var iterator = scope.events.makeAsyncIterator() + guard case .reset = try #require(try await iterator.next()) else { + Issue.record("Expected the element-picker scope's initial reset.") + return + } + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: modelFeedInspectorInspectMessage(objectID: "after-activation") + )) + guard case let .event(_, event) = try #require(try await iterator.next()), + case let .inspect(object, _) = event else { + Issue.record("Expected the post-activation Inspector.inspect event.") + return + } + #expect(object.id == Runtime.RemoteObject.ID("after-activation")) + + let releaseBaseline = await backend.sentTargetMessages().count + let release = Task { + try await core.releaseEventScope(scope.id) + } + try await modelFeedCompleteElementPickerRelease( + core: core, + backend: backend, + targetID: "page-main", + after: releaseBaseline + ) + try await release.value + await core.close() +} + +@Test +func directElementPickerReinitializesAndReactivatesOnReplacementPage() async throws { + let backend = FakeTransportBackend() + let parser = ModelFeedArmedMessageParser() + let core = ConnectionCore( + backend: backend, + responseTimeout: nil, + messageParser: { try await parser.parse($0) } + ) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + + let scopeTask = Task { + try await modelFeedAcquireDirectElementPickerScope(core: core) + } + try await modelFeedCompleteElementPickerAcquisition( + core: core, + backend: backend, + targetID: "page-old", + expectsInitialization: true + ) + let scope = try await scopeTask.value + var iterator = scope.events.makeAsyncIterator() + guard case .reset = try #require(try await iterator.next()) else { + Issue.record("Expected the old page generation reset.") + return + } + + let replacementBaseline = await backend.sentTargetMessages().count + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame", + isProvisional: true + )) + _ = await core.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# + ) + + let enable = try await backend.waitForTargetMessage( + method: "Inspector.enable", + after: replacementBaseline + ) + #expect(enable.targetIdentifier == ProtocolTarget.ID("page-new")) + await modelFeedRespond(to: enable, core: core) + let initialized = try await backend.waitForTargetMessage( + method: "Inspector.initialized", + after: replacementBaseline + ) + #expect(initialized.targetIdentifier == ProtocolTarget.ID("page-new")) + await modelFeedRespond(to: initialized, core: core) + let activate = try await backend.waitForTargetMessage( + method: "DOM.setInspectModeEnabled", + after: replacementBaseline + ) + #expect(activate.targetIdentifier == ProtocolTarget.ID("page-new")) + let activationPendingKey = TransportSession.PendingKey.target( + TransportSession.ReplyKey( + targetID: activate.targetIdentifier, + commandID: try modelFeedMessageID(activate.message) + ) + ) + let activationPurpose = try #require( + await core.pendingReplyPurposes()[activationPendingKey] + ) + guard case let .elementPickerMode(key, generation, _, enabled) = activationPurpose else { + Issue.record("Expected the inspect-mode reply to retain its picker owner.") + return + } + #expect(key.route == .currentPage) + #expect(key.targetID == .currentPage) + #expect(key.domain == .inspector) + #expect(generation == (try await core.pageGeneration())) + #expect(enabled) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-new", + message: modelFeedInspectorInspectMessage(objectID: "before-new-activation") + )) + await parser.armNextInvocation() + let activationReply = Task { + await modelFeedRespond(to: activate, core: core) + } + await parser.waitUntilBlocked() + let immediateInspect = Task { + await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-new", + message: modelFeedInspectorInspectMessage(objectID: "after-new-activation") + )) + } + _ = await immediateInspect.value + await parser.release() + await activationReply.value + guard case .reset = try #require(try await iterator.next()) else { + Issue.record("Expected the replacement page generation reset.") + return + } + guard case let .event(_, event) = try #require(try await iterator.next()), + case let .inspect(object, _) = event else { + Issue.record("Expected only the post-reactivation inspect event.") + return + } + #expect(object.id == Runtime.RemoteObject.ID("after-new-activation")) + + let releaseBaseline = await backend.sentTargetMessages().count + let releaseTask = Task { + try await core.releaseEventScope(scope.id) + } + try await modelFeedCompleteElementPickerRelease( + core: core, + backend: backend, + targetID: "page-new", + after: releaseBaseline + ) + try await releaseTask.value + await core.close() +} + +@Test +func modelFeedElementPickerPublishesOnlyActivatedInspectAndClosesInWireOrder() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom], + targetID: "page-main" + ) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + _ = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + _ = try modelFeedRequireBootstrapCompletion(try await iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + let acquireTask = Task { + try await feed.acquireElementPicker() + } + let enable = try await backend.waitForTargetMessage(method: "Inspector.enable") + await modelFeedRespond(to: enable, core: core) + let initialized = try await backend.waitForTargetMessage(method: "Inspector.initialized") + await modelFeedRespond(to: initialized, core: core) + let activate = try await backend.waitForTargetMessage(method: "DOM.setInspectModeEnabled") + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: modelFeedInspectorInspectMessage(objectID: "before-activation") + )) + await modelFeedRespond(to: activate, core: core) + try await acquireTask.value + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: modelFeedInspectorInspectMessage(objectID: "after-activation") + )) + let record = try modelFeedRequireEvent(try await iterator.next()) + guard case let .inspector(target, event) = record.payload, + case let .inspect(object, _) = event else { + Issue.record("Expected the activated Inspector.inspect model record.") + return + } + #expect(target.id == WebInspectorTarget.ID("page-main")) + #expect(object.id == Runtime.RemoteObject.ID("after-activation")) + + let closeBaseline = await backend.sentTargetMessages().count + let closeTask = Task { + try await feed.close() + } + try await modelFeedCompleteElementPickerRelease( + core: core, + backend: backend, + targetID: "page-main", + after: closeBaseline + ) + try await closeTask.value + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func cssModelFeedUsesNormalizedDOMBootstrapAndOneSynchronization() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.css], + targetID: "page-main" + ) + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let bootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + var sawDOMCompletion = false + var sawCSSReplay = false + for _ in 0..<2 { + let record = try #require(await iterator.next()) + switch record { + case .bootstrapComplete: + let completion = try modelFeedRequireBootstrapCompletion(record) + #expect(completion.domain == .dom) + sawDOMCompletion = true + case .replayComplete: + let replay = try modelFeedRequireReplayCompletion(record) + #expect(replay.domain == .css) + sawCSSReplay = true + default: + Issue.record("Expected normalized DOM and CSS completion boundaries.") + } + } + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + #expect(bootstrap.generation == reset) + #expect(sawDOMCompletion) + #expect(sawCSSReplay) + #expect(synchronization.generation == reset) + + let domKey = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: .dom + ) + #expect(await core.capabilityLeaseOwnersForTesting()[domKey] == Set([ + .modelFeed(feed.id, .dom), + .modelFeed(feed.id, .css), + ])) + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Page.enable", "CSS.enable"] + ) + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func multiDomainSynchronizationAcceptsReplayBeforeDOMBootstrap() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.dom, .network] + ) + } + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + let feed = try await openTask.value + + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + let targetSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let replay = try await modelFeedRequireReplayCompletion(iterator.next()) + #expect(replay.generation == reset) + #expect(replay.domain == .network) + + await modelFeedRespondWithDocument( + to: getDocument, + core: core, + nodeID: "document" + ) + let bootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let bootstrapCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + #expect(bootstrap.generation == reset) + #expect(bootstrapCompletion.generation == reset) + #expect(bootstrapCompletion.domain == .dom) + #expect(synchronization.generation == reset) + #expect(synchronization.through >= targetSnapshot.through) + + let closeTask = Task { + try await feed.close() + } + let networkDisable = try await backend.waitForTargetMessage(method: "Network.disable") + await modelFeedRespond(to: networkDisable, core: core) + try await closeTask.value + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func modelFeedAtomicallyStartsWithResetTargetSnapshotAndEmptySynchronization() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-b", + type: "frame", + frameID: "frame-b", + parentFrameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + let through = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-child", + type: "frame", + frameID: "frame-child", + parentFrameID: "frame-a" + )) + + let feed = try await core.openModelFeed(configuredDomains: []) + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + + #expect(reset.rawValue == 1) + #expect(snapshot.generation == reset) + #expect(snapshot.through == through) + #expect(snapshot.snapshot.currentPageID == WebInspectorTarget.ID("page-main")) + #expect(snapshot.snapshot.targets.map(\.id) == [ + WebInspectorTarget.ID("page-main"), + WebInspectorTarget.ID("frame-a"), + WebInspectorTarget.ID("frame-b"), + WebInspectorTarget.ID("frame-child"), + ]) + #expect(synchronization.generation == reset) + #expect(synchronization.through == through) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func modelFeedUnavailableBindingUsesOneGenerationForResetSnapshotAndSynchronization() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + let feed = try await core.openModelFeed(configuredDomains: []) + var iterator = feed.records.makeAsyncIterator() + + let reset = try await modelFeedRequireReset(iterator.next()) + #expect(reset.rawValue == 1) + + let through = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + + #expect(snapshot.generation == reset) + #expect(snapshot.through == through) + #expect(synchronization.generation == reset) + #expect(synchronization.through == through) + + // The snapshot watermark subsumes the Target.targetCreated event that + // established the binding. Closing immediately must not expose a duplicate + // lifecycle delta at that same sequence. + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func modelFeedPublishesFutureTargetAndConfiguredDomainEventsAfterSnapshotWatermark() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed(configuredDomains: [.network]) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + let enableID = try modelFeedMessageID(enable.message) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"id":\#(enableID),"result":{}}"# + )) + let feed = try await openTask.value + #expect(try await backend.sentTargetMessages().allSatisfy { + try modelFeedMessageMethod($0.message) != "DOM.getDocument" + }) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + let initialSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let initialReplay = try await modelFeedRequireReplayCompletion(iterator.next()) + let initialSynchronization = try await modelFeedRequireSynchronization(iterator.next()) + #expect(initialReplay.generation == initialSnapshot.generation) + #expect(initialReplay.domain == .network) + #expect(initialReplay.through == initialSnapshot.through) + #expect(initialSynchronization.generation == initialSnapshot.generation) + #expect(initialSynchronization.through == initialSnapshot.through) + + let targetSequence = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + let targetEvent = try await modelFeedRequireEvent(iterator.next()) + #expect(targetEvent.sequence == targetSequence) + #expect(targetEvent.sequence > initialSnapshot.through) + guard case let .target(.targetCreated(target)) = targetEvent.payload else { + Issue.record("Expected a model targetCreated delta.") + return + } + #expect(target.id == WebInspectorTarget.ID("frame-a")) + + _ = await core.receiveRootMessage( + #"{"method":"DOM.documentUpdated","params":{}}"# + ) + let networkSequence = await core.receiveRootMessage( + #"{"method":"Network.requestWillBeSent","params":{"requestId":"request-1","request":{"url":"https://example.test","method":"GET"},"timestamp":1,"type":"Document"}}"# + ) + let networkEvent = try await modelFeedRequireEvent(iterator.next()) + #expect(networkEvent.sequence == networkSequence) + guard case let .network(target, event) = networkEvent.payload else { + Issue.record("Expected a physical-target Network event.") + return + } + #expect(target.id == WebInspectorTarget.ID("page-main")) + guard case let .requestWillBeSent(id, _, _, _, _) = event else { + Issue.record("Expected Network.requestWillBeSent.") + return + } + #expect(id == Network.Request.ID("request-1")) + + let closeTask = Task { + try await feed.close() + } + let disable = try await backend.waitForTargetMessage(method: "Network.disable") + let disableID = try modelFeedMessageID(disable.message) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"id":\#(disableID),"result":{}}"# + )) + try await closeTask.value + await core.close() +} + +@Test +func modelFeedReplayCompletionFollowsEnableTimeEventsBeforeOpenReturns() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed(configuredDomains: [.network]) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + + let replayedEventSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"Network.requestWillBeSent","params":{"requestId":"enable-replay","request":{"url":"https://example.test/replay","method":"GET"},"timestamp":1,"type":"Document"}}"# + ) + ) + await modelFeedRespond(to: enable, core: core) + let feed = try await openTask.value + + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let replayedEvent = try await modelFeedRequireEvent(iterator.next()) + let replayCompletion = try await modelFeedRequireReplayCompletion(iterator.next()) + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + + #expect(replayedEvent.sequence == replayedEventSequence) + #expect(replayedEvent.sequence > snapshot.through) + guard case let .network(_, event) = replayedEvent.payload, + case let .requestWillBeSent(id, _, _, _, _) = event else { + Issue.record("Expected the enable-time Network event before replay completion.") + return + } + #expect(id == Network.Request.ID("enable-replay")) + #expect(replayCompletion.generation == snapshot.generation) + #expect(replayCompletion.domain == .network) + #expect(replayCompletion.through == replayedEventSequence) + #expect(synchronization.generation == snapshot.generation) + #expect(synchronization.through == replayedEventSequence) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Network.enable"] + ) + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func cleanModelFeedCloseReleasesClaimForAReplacementFeed() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let firstFeed = try await core.openModelFeed(configuredDomains: []) + var firstIterator = firstFeed.records.makeAsyncIterator() + _ = try await firstIterator.next() + _ = try await firstIterator.next() + _ = try await firstIterator.next() + try await firstFeed.close() + #expect(try await firstIterator.next() == nil) + + let replacementFeed = try await core.openModelFeed(configuredDomains: []) + var replacementIterator = replacementFeed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(replacementIterator.next()) + _ = try await modelFeedRequireTargetSnapshot(replacementIterator.next()) + _ = try await modelFeedRequireSynchronization(replacementIterator.next()) + try await replacementFeed.close() + #expect(try await replacementIterator.next() == nil) + await core.close() +} + +@Test +func cancelledIteratorRequiresExplicitFeedCloseBeforeReplacement() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let ready = ModelFeedProbe() + let consumer = Task { + var iterator = feed.records.makeAsyncIterator() + _ = try await iterator.next() + _ = try await iterator.next() + _ = try await iterator.next() + await ready.finish() + return try await iterator.next() + } + await ready.waitUntilFinished() + + consumer.cancel() + await #expect(throws: CancellationError.self) { + try await consumer.value + } + await #expect(throws: ConnectionModelFeedError.alreadyOpen) { + try await core.openModelFeed(configuredDomains: []) + } + + try await feed.close() + let replacementFeed = try await core.openModelFeed(configuredDomains: []) + var replacementIterator = replacementFeed.records.makeAsyncIterator() + _ = try await replacementIterator.next() + _ = try await replacementIterator.next() + _ = try await replacementIterator.next() + try await replacementFeed.close() + #expect(try await replacementIterator.next() == nil) + await core.close() +} + +@Test +func admittedDirectCommandPermanentlyPreventsOpeningModelFeed() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + let commandTask = Task { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.getTargets", + routing: .root + )) + } + let sentMessage = try await backend.waitForMessage() + + await #expect(throws: ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer) { + try await core.openModelFeed(configuredDomains: []) + } + + let commandID = try modelFeedMessageID(sentMessage) + _ = await core.receiveRootMessage(#"{"id":\#(commandID),"result":{}}"#) + _ = try await commandTask.value + await #expect(throws: ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer) { + try await core.openModelFeed(configuredDomains: []) + } + await core.close() +} + +@Test +func modelFeedAndDirectConsumersClaimConnectionExclusivelyInBothOrders() async throws { + let directFirstCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await directFirstCore.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = try await directFirstCore.send(ProtocolCommand( + domain: .dom, + method: "DOM.enable", + routing: .octopus(pageTarget: nil) + )) + await #expect(throws: ConnectionModelFeedError.connectionAlreadyUsedByDirectConsumer) { + try await directFirstCore.openModelFeed(configuredDomains: []) + } + await directFirstCore.close() + + let feedFirstCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await feedFirstCore.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await feedFirstCore.openModelFeed(configuredDomains: []) + await #expect(throws: ConnectionModelFeedError.alreadyOpen) { + try await feedFirstCore.openModelFeed(configuredDomains: []) + } + await #expect(throws: WebInspectorProxyError.connectionInUse) { + try await feedFirstCore.send(ProtocolCommand( + domain: .target, + method: "Target.getTargets", + routing: .root + )) + } + await #expect(throws: WebInspectorProxyError.connectionInUse) { + let _: WebInspectorProxyEventScope = try await feedFirstCore.acquireEventScope( + route: .currentPage, + targetID: .currentPage, + domain: .network, + buffering: .bounded(8), + extract: { event in + guard case let .network(value) = event else { + return nil + } + return value + } + ) + } + + try await feed.close() + try await feed.close() + await feedFirstCore.close() +} + +@Test +func explicitConnectionCloseFinishesModelFeedOnlyAfterCloseQuiescence() async throws { + let closeGate = ModelFeedAsyncGate() + let core = ConnectionCore( + backend: FakeTransportBackend(), + responseTimeout: nil, + closeAction: { + await closeGate.waitUntilReleased() + } + ) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let observerReady = ModelFeedProbe() + let observerFinished = ModelFeedProbe() + let observer = Task { + var iterator = feed.records.makeAsyncIterator() + _ = try await iterator.next() + _ = try await iterator.next() + _ = try await iterator.next() + await observerReady.finish() + let terminal = try await iterator.next() + await observerFinished.finish() + return terminal == nil + } + await observerReady.waitUntilFinished() + + let closeTask = Task { + await core.close() + } + await closeGate.waitUntilStarted() + #expect(await observerFinished.isFinished == false) + + await closeGate.release() + await closeTask.value + #expect(try await observer.value) +} + +@Test +func fatalAndProtocolTerminationFailModelFeed() async throws { + let fatalCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await fatalCore.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let fatalFeed = try await fatalCore.openModelFeed(configuredDomains: []) + var fatalIterator = fatalFeed.records.makeAsyncIterator() + _ = try await fatalIterator.next() + _ = try await fatalIterator.next() + _ = try await fatalIterator.next() + let fatalHandoff = try #require(fatalCore.failFromNativeCallback("feed fatal")) + await fatalHandoff.value + await #expect(throws: WebInspectorProxyError.transportFailure("feed fatal")) { + try await fatalIterator.next() + } + + let protocolCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await protocolCore.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let protocolFeed = try await protocolCore.openModelFeed(configuredDomains: []) + var protocolIterator = protocolFeed.records.makeAsyncIterator() + _ = try await protocolIterator.next() + _ = try await protocolIterator.next() + _ = try await protocolIterator.next() + _ = await protocolCore.receiveRootMessage("not-json") + await #expect(throws: WebInspectorProxyError.protocolViolation("Malformed root protocol message.")) { + try await protocolIterator.next() + } +} + +@Test +func droppingModelFeedFinishesMailboxButKeepsConnectionClaimedUntilClose() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + var feed: ConnectionModelFeed? = try await core.openModelFeed( + configuredDomains: [] + ) + weak let weakFeed = feed + let records = try #require(feed).records + feed = nil + + var iterator = records.makeAsyncIterator() + _ = try await iterator.next() + _ = try await iterator.next() + _ = try await iterator.next() + #expect(try await iterator.next() == nil) + #expect(weakFeed == nil) + await #expect(throws: ConnectionModelFeedError.alreadyOpen) { + try await core.openModelFeed(configuredDomains: []) + } + await #expect(throws: WebInspectorProxyError.connectionInUse) { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.getTargets", + routing: .root + )) + } + await core.close() +} + +@Test +func targetMutationCannotReenterBeforeSnapshotPublication() async throws { + let core = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + let feed = try await core.openModelFeed(configuredDomains: []) + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + let mutationGate = ModelFeedSynchronousGate() + await core.replaceModelTargetMutationActionForTesting { + mutationGate.block() + } + + let receiveTask = Task { + await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + } + await mutationGate.waitUntilBlocked() + + let generationStarted = ModelFeedProbe() + let generationFinished = ModelFeedProbe() + let generationTask = Task { + await generationStarted.finish() + let generation = try await core.pageGeneration() + await generationFinished.finish() + return generation + } + await generationStarted.waitUntilFinished() + #expect(await generationFinished.isFinished == false) + + mutationGate.release() + let eventSequence = await receiveTask.value + let generation = try await generationTask.value + let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + + #expect(generation == reset) + #expect(snapshot.generation == reset) + #expect(snapshot.through == eventSequence) + + await core.replaceModelTargetMutationActionForTesting(nil) + try await feed.close() + await core.close() +} + +@Test +func replacementMainPageWaiterResumesAfterCapabilityOwnershipIsReconciled() async throws { + let waiterRegistered = ModelFeedProbe() + let backend = FakeTransportBackend() + let core = ConnectionCore( + backend: backend, + responseTimeout: nil, + timeoutSleep: { _ in + await waiterRegistered.finish() + try await Task.sleep(for: .seconds(30)) + } + ) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + + let scopeTask = Task { + let scope: WebInspectorProxyEventScope = try await core.acquireEventScope( + route: .currentPage, + targetID: .currentPage, + domain: .network, + buffering: .bounded(8), + extract: { event in + guard case let .network(value) = event else { + return nil + } + return value + } + ) + return scope + } + let initialEnable = try await backend.waitForTargetMessage(method: "Network.enable") + let initialEnableID = try modelFeedMessageID(initialEnable.message) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-old", + message: #"{"id":\#(initialEnableID),"result":{}}"# + )) + let scope = try await scopeTask.value + + _ = await core.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# + ) + let waiterTask = Task { + try await core.waitForCurrentMainPageTarget(timeout: .seconds(1)) + } + await waiterRegistered.waitUntilFinished() + + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame" + )) + let replacement = try await waiterTask.value + let pendingPurposes = await core.pendingReplyPurposes() + + #expect(replacement.targetID == ProtocolTarget.ID("page-new")) + #expect(pendingPurposes.count == 1) + let pending = try #require(pendingPurposes.first) + guard case let .target(replyKey) = pending.key, + case let .capability(capabilityKey, generation, _) = pending.value else { + Issue.record("Expected one reconciled current-page capability owner.") + await core.close() + _ = scope + return + } + #expect(replyKey.targetID == ProtocolTarget.ID("page-new")) + #expect(replyKey.targetID != ProtocolTarget.ID("page-old")) + #expect(capabilityKey.route == .currentPage) + #expect(capabilityKey.targetID == .currentPage) + #expect(capabilityKey.domain == .network) + #expect(generation.rawValue == 2) + + await core.close() + _ = scope +} + +@Test +func modelFeedAcquiresAndReleasesConfiguredCapabilitiesInDeterministicOrder() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let configuredDomains = Set(ModelDomain.acquisitionOrder) + let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: configuredDomains, + targetID: "page-main" + ) + + #expect(enableMethods == [ + "Page.enable", + "CSS.enable", + "Network.enable", + "Console.enable", + "Runtime.enable", + ]) + #expect(try await modelFeedSentTargetMethods(backend) == enableMethods) + + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + let snapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + #expect(snapshot.generation == reset) + let bootstrapSnapshot = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + var bootstrapCompletion: ModelFeedBootstrapCompletionRecord? + var replayCompletions: [ModelFeedReplayCompletionRecord] = [] + for _ in 0..<5 { + let record = try #require(await iterator.next()) + switch record { + case .bootstrapComplete: + #expect(bootstrapCompletion == nil) + bootstrapCompletion = try modelFeedRequireBootstrapCompletion(record) + case .replayComplete: + replayCompletions.append(try modelFeedRequireReplayCompletion(record)) + default: + Issue.record("Expected one DOM bootstrap completion or replay completion.") + } + } + #expect(replayCompletions.map(\.domain) == [ + .css, + .network, + .console, + .runtime, + ]) + #expect(replayCompletions.allSatisfy { + $0.generation == reset && $0.through == snapshot.through + }) + let synchronization = try await modelFeedRequireSynchronization(iterator.next()) + #expect(bootstrapSnapshot.generation == reset) + #expect(bootstrapSnapshot.target.id == WebInspectorTarget.ID("page-main")) + #expect(bootstrapSnapshot.documentEpoch == ModelDocumentEpoch(rawValue: 0)) + #expect(bootstrapCompletion?.generation == reset) + #expect(bootstrapCompletion?.domain == .dom) + #expect(synchronization.generation == reset) + + let owners = await core.capabilityLeaseOwnersForTesting() + let domKey = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: .dom + ) + #expect(owners[domKey] == Set([ + .modelFeed(feed.id, .dom), + .modelFeed(feed.id, .css), + ])) + for domain in [ + WebInspectorProxyEventDomain.css, + .network, + .console, + .runtime, + ] { + let key = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: domain + ) + #expect(owners[key]?.count == 1) + } + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: enableMethods + ) + #expect(try await modelFeedSentTargetMethods(backend) == enableMethods + [ + "Runtime.disable", + "Console.disable", + "Network.disable", + "CSS.disable", + "Page.disable", + ]) + // The shared local DOM lease emits no replay marker, and each physical + // wire capability emits exactly one marker. + #expect(try await iterator.next() == nil) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + await core.close() +} + +@Test +func registeredModelFeedConsumerDrainsEnableReplayBeforeOpenReturns() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let consumerTask = Mutex?>(nil) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network], + onRegistered: { feed in + let task = Task { + var records: [ConnectionModelFeedRecord] = [] + for try await record in feed.records { + records.append(record) + if case .synchronizationComplete = record { + return records + } + } + Issue.record("The model feed ended before synchronization.") + return records + } + consumerTask.withLock { value in + value = task + } + return true + } + ) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + + let replayEventCount = 512 + for index in 0.. ModelProtocolEvent? in + guard case let .event(_, _, payload) = record else { + return nil + } + return payload + } + #expect(eventRecords.count == replayEventCount) + #expect(await core.terminalCause == nil) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Network.enable"] + ) + await core.close() +} + +@Test +func rejectedEnableDoesNotPublishReplayMarkerOrPoisonFullFeed() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network] + ) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond( + to: enable, + core: core, + errorMessage: "enable rejected" + ) + + await #expect(throws: ConnectionModelFeedError.bootstrapFailed( + domain: .network, + message: "enable rejected" + )) { + try await openTask.value + } + #expect(await core.terminalCause == nil) + #expect(await backend.isDetached() == false) + + let replacement = try await core.openModelFeed( + configuredDomains: [] + ) + try await replacement.close() + await core.close() +} + +@Test +func modelFeedActivationPageUnavailableRemainsConnectionLifecycleError() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + await backend.setSendError(WebInspectorProxyError.pageUnavailable) + + await #expect(throws: WebInspectorProxyError.pageUnavailable) { + try await core.openModelFeed(configuredDomains: [.network]) + } + #expect(await core.terminalCause == nil) + #expect(await backend.isDetached() == false) + + await backend.setSendError(nil) + let replacement = try await core.openModelFeed(configuredDomains: []) + try await replacement.close() + await core.close() +} + +@Test(arguments: [ + WebInspectorProxyError.transportFailure("activation transport failed"), + WebInspectorProxyError.protocolViolation("activation reply malformed"), +]) +func modelFeedActivationTerminalErrorIsNotRelabeledAsBootstrap( + activationError: WebInspectorProxyError +) async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + await backend.setSendError(activationError) + + do { + _ = try await core.openModelFeed(configuredDomains: [.network]) + Issue.record("Expected activation to terminate the connection.") + } catch let error as WebInspectorScopeError { + #expect(error.operationError as? WebInspectorProxyError == activationError) + #expect((error.operationError is ConnectionModelFeedError) == false) + #expect((error.cleanupError is ConnectionModelFeedError) == false) + } catch { + Issue.record("Expected WebInspectorScopeError, got \(error).") + } + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(await backend.isDetached()) +} + +@Test +func cancelledActivationDoesNotPublishReplayMarkerAfterOwnerStopsBeingDesired() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let key = ConnectionCapabilityKey( + route: .currentPage, + targetID: .currentPage, + domain: .network + ) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network] + ) + } + let enable = try await backend.waitForTargetMessage(method: "Network.enable") + + openTask.cancel() + #expect(await modelFeedWaitForNoDesiredCapabilityOwners(core, key: key)) + await modelFeedRespond(to: enable, core: core) + let disable = try await backend.waitForTargetMessage(method: "Network.disable") + await modelFeedRespond(to: disable, core: core) + + await #expect(throws: CancellationError.self) { + try await openTask.value + } + #expect(await core.terminalCause == nil) + #expect(await backend.isDetached() == false) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + + let replacement = try await core.openModelFeed( + configuredDomains: [] + ) + try await replacement.close() + await core.close() +} + +@Test(arguments: [0, 1, 2, 3, 4]) +func modelFeedCapabilityFailureRollsBackSuccessfulPrefixInReverseOrder( + failureIndex: Int +) async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let configuredDomains = Set(ModelDomain.acquisitionOrder) + let enableMethods = modelFeedExpectedEnableMethods(configuredDomains) + let enableModelDomains: [ModelDomain] = [ + .css, + .css, + .network, + .console, + .runtime, + ] + let openTask = Task { + try await core.openModelFeed( + configuredDomains: configuredDomains + ) + } + + for index in 0...failureIndex { + let message = try await backend.waitForTargetMessage( + method: enableMethods[index] + ) + #expect(try modelFeedMessageMethod(message.message) == enableMethods[index]) + if index == failureIndex { + await modelFeedRespond( + to: message, + core: core, + errorMessage: "rejected-\(failureIndex)" + ) + } else { + await modelFeedRespond(to: message, core: core) + } + } + + let rollbackMethods = Array(enableMethods[.. replacementSnapshot.through) + #expect(replacementReplay.generation == replacementReset) + #expect(replacementReplay.domain == .network) + #expect(replacementReplay.through == replacementEventSequence) + #expect(replacementSynchronization.generation == replacementReset) + #expect(replacementSynchronization.through == replacementEventSequence) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-new", + enableMethods: ["Network.enable"] + ) + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func domModelFeedCapabilityIsLocalButParticipatesInLeaseTransaction() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom], + targetID: "page-main" + ) + #expect(try await modelFeedSentTargetMethods(backend).isEmpty) + #expect(await modelFeedAllCapabilityLeaseOwners(core) == Set([ + .modelFeed(feed.id, .dom), + ])) + + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + _ = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + try await feed.close() + #expect(try await modelFeedSentTargetMethods(backend).isEmpty) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + await core.close() +} + +@Test +func domBootstrapRequestsMainAndCommittedFramesInSnapshotOrder() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-b", + type: "frame", + frameID: "frame-b", + parentFrameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + let mainDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + #expect(mainDocument.targetIdentifier == ProtocolTarget.ID("page-main")) + #expect(await backend.sentTargetMessages().count == 1) + await modelFeedRespondWithDocument(to: mainDocument, core: core, nodeID: "1") + + let firstFrameDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + #expect(firstFrameDocument.targetIdentifier == ProtocolTarget.ID("frame-a")) + #expect(await backend.sentTargetMessages().count == 2) + await modelFeedRespondWithDocument(to: firstFrameDocument, core: core, nodeID: "1") + + let secondFrameDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 2 + ) + #expect(secondFrameDocument.targetIdentifier == ProtocolTarget.ID("frame-b")) + await modelFeedRespondWithDocument(to: secondFrameDocument, core: core, nodeID: "1") + + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let snapshots = try await [ + modelFeedRequireDOMBootstrapSnapshot(iterator.next()), + modelFeedRequireDOMBootstrapSnapshot(iterator.next()), + modelFeedRequireDOMBootstrapSnapshot(iterator.next()), + ] + #expect(snapshots.map(\.target.id) == [ + WebInspectorTarget.ID("page-main"), + WebInspectorTarget.ID("frame-a"), + WebInspectorTarget.ID("frame-b"), + ]) + #expect(snapshots.allSatisfy { + $0.generation == reset && $0.documentEpoch == ModelDocumentEpoch(rawValue: 0) + }) + #expect(snapshots[0].root.id == DOM.Node.ID("1")) + #expect(snapshots[1].root.id != DOM.Node.ID("1")) + #expect(snapshots[2].root.id != DOM.Node.ID("1")) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func domDocumentInvalidationPrecedesMainTargetDeltasAndFreshBootstrap() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let openTask = Task { + try await core.openModelFeed(configuredDomains: [.css]) + } + let staleDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + let pageEnable = try await backend.waitForTargetMessage(method: "Page.enable") + await modelFeedRespond(to: pageEnable, core: core) + let cssEnable = try await backend.waitForTargetMessage(method: "CSS.enable") + await modelFeedRespond(to: cssEnable, core: core) + let feed = try await openTask.value + var iterator = feed.records.makeAsyncIterator() + let reset = try await modelFeedRequireReset(iterator.next()) + let targetSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let cssReplay = try await modelFeedRequireReplayCompletion(iterator.next()) + #expect(cssReplay.domain == .css) + + let oldAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: reset, + document: ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("page-main"), + epoch: ModelDocumentEpoch(rawValue: 0) + ) + ) + let oldCommand = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: oldAuthorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) + + let invalidationSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + ) + ) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await oldCommand.value + } + let invalidation = try await modelFeedRequireDOMDocumentInvalidation(iterator.next()) + #expect(invalidation.generation == reset) + #expect(invalidation.sequence == invalidationSequence) + #expect(invalidation.target.id == WebInspectorTarget.ID("page-main")) + #expect(invalidation.documentEpoch == ModelDocumentEpoch(rawValue: 1)) + + let domSequence = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":1,"childNodeCount":2}}"# + )) + let cssSequence = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-main","origin":"author"}}}"# + )) + let domEvent = try await modelFeedRequireEvent(iterator.next()) + let cssEvent = try await modelFeedRequireEvent(iterator.next()) + #expect(invalidation.sequence < domEvent.sequence) + #expect(domEvent.sequence == domSequence) + #expect(domEvent.sequence < cssEvent.sequence) + #expect(cssEvent.sequence == cssSequence) + guard case let .dom(domTarget, _) = domEvent.payload, + case let .css(cssTarget, _) = cssEvent.payload else { + Issue.record("Expected ordered DOM and CSS deltas after invalidation.") + return + } + #expect(domTarget.id == WebInspectorTarget.ID("page-main")) + #expect(cssTarget.id == WebInspectorTarget.ID("page-main")) + + await modelFeedRespondWithDocument(to: staleDocument, core: core, nodeID: "stale") + let retryDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + #expect(retryDocument.targetIdentifier == ProtocolTarget.ID("page-main")) + await modelFeedRespondWithDocument(to: retryDocument, core: core, nodeID: "fresh") + + let initialBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + #expect(initialBootstrap.generation == reset) + #expect(initialBootstrap.target.id == WebInspectorTarget.ID("page-main")) + #expect(initialBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 1)) + #expect(initialBootstrap.root.id == DOM.Node.ID("fresh")) + #expect(initialBootstrap.sequence == cssSequence) + let initialCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) + let initialSync = try await modelFeedRequireSynchronization(iterator.next()) + #expect(initialCompletion.through == initialBootstrap.sequence) + #expect(initialSync.generation == targetSnapshot.generation) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Page.enable", "CSS.enable"] + ) + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func frameDocumentUpdatedBypassesPublicFilterWithOneModelInvalidationBoundary() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + var iterator = feed.records.makeAsyncIterator() + let generation = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + let mainDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + await modelFeedRespondWithDocument(to: mainDocument, core: core, nodeID: "main") + let frameDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + await modelFeedRespondWithDocument(to: frameDocument, core: core, nodeID: "frame") + _ = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let initialFrameBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + #expect(initialFrameBootstrap.target.id == WebInspectorTarget.ID("frame-a")) + #expect(initialFrameBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 0)) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + let publicFrameDocumentEvent = ProtocolEvent( + sequence: 1, + domain: .dom, + method: "DOM.documentUpdated", + targetID: ProtocolTarget.ID("frame-a"), + paramsData: Data("{}".utf8) + ) + #expect(ConnectionEventProjection.shouldDeliver( + publicFrameDocumentEvent, + to: .currentPage, + in: await core.snapshot() + ) == false) + + let oldAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation, + document: ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("frame-a"), + epoch: ModelDocumentEpoch(rawValue: 0) + ) + ) + let oldCommand = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: oldAuthorization, + routing: .target(ProtocolTarget.ID("frame-a")) + )) + } + let oldCommandMessage = try await backend.waitForTargetMessage( + method: "DOM.querySelector" + ) + + let invalidationSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "frame-a", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + ) + ) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await oldCommand.value + } + let invalidation = try await modelFeedRequireDOMDocumentInvalidation(iterator.next()) + #expect(invalidation.generation == generation) + #expect(invalidation.sequence == invalidationSequence) + #expect(invalidation.target.id == WebInspectorTarget.ID("frame-a")) + #expect(invalidation.documentEpoch == ModelDocumentEpoch(rawValue: 1)) + await modelFeedRespond(to: oldCommandMessage, core: core) + + let laterDOMSequence = await core.receiveRootMessage( + modelFeedTargetDispatchMessage( + targetID: "frame-a", + message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":7,"childNodeCount":1}}"# + ) + ) + let laterDOMEvent = try await modelFeedRequireEvent(iterator.next()) + #expect(invalidation.sequence < laterDOMEvent.sequence) + #expect(laterDOMEvent.sequence == laterDOMSequence) + guard case let .dom(target, _) = laterDOMEvent.payload else { + Issue.record("Expected a frame DOM delta after its invalidation boundary.") + return + } + #expect(target.id == WebInspectorTarget.ID("frame-a")) + + let refreshedFrameDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 2 + ) + #expect(refreshedFrameDocument.targetIdentifier == ProtocolTarget.ID("frame-a")) + await modelFeedRespondWithDocument( + to: refreshedFrameDocument, + core: core, + nodeID: "fresh-frame" + ) + let refreshedFrameBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + #expect(refreshedFrameBootstrap.generation == generation) + #expect(refreshedFrameBootstrap.sequence == laterDOMSequence) + #expect(refreshedFrameBootstrap.target.id == WebInspectorTarget.ID("frame-a")) + #expect(refreshedFrameBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 1)) + #expect(refreshedFrameBootstrap.root.id.targetScopeRawValue == "frame-a") + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func domBootstrapTracksTargetsAddedAndDestroyedBeforeInitialSync() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + let mainDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-b", + type: "frame", + frameID: "frame-b", + parentFrameID: "main-frame" + )) + let frameAEvent = try await modelFeedRequireEvent(iterator.next()) + let frameBEvent = try await modelFeedRequireEvent(iterator.next()) + guard case let .target(.targetCreated(frameA)) = frameAEvent.payload, + case let .target(.targetCreated(frameB)) = frameBEvent.payload else { + Issue.record("Expected targetCreated deltas before bootstrap completion.") + return + } + #expect(frameA.id == WebInspectorTarget.ID("frame-a")) + #expect(frameB.id == WebInspectorTarget.ID("frame-b")) + + await modelFeedRespondWithDocument(to: mainDocument, core: core, nodeID: "main") + let frameADocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + #expect(frameADocument.targetIdentifier == ProtocolTarget.ID("frame-a")) + await modelFeedRespondWithDocument(to: frameADocument, core: core, nodeID: "a") + let frameBDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 2 + ) + #expect(frameBDocument.targetIdentifier == ProtocolTarget.ID("frame-b")) + + _ = await core.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"frame-b"}}"# + ) + let mainBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let frameABootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let frameBDestroyed = try await modelFeedRequireEvent(iterator.next()) + guard case let .target(.targetDestroyed(target)) = frameBDestroyed.payload else { + Issue.record("Expected the removed frame target delta.") + return + } + #expect(target.id == WebInspectorTarget.ID("frame-b")) + // This late reply no longer owns a pending operation and cannot publish. + await modelFeedRespondWithDocument(to: frameBDocument, core: core, nodeID: "stale-b") + + #expect(mainBootstrap.target.id == WebInspectorTarget.ID("page-main")) + #expect(frameABootstrap.target.id == WebInspectorTarget.ID("frame-a")) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + #expect(await backend.sentTargetMessages().filter { + (try? modelFeedMessageMethod($0.message)) == "DOM.getDocument" + }.count == 3) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func domBootstrapRestartsWithFreshEpochAndSyncAfterRetarget() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom], + targetID: "page-old" + ) + var iterator = feed.records.makeAsyncIterator() + let initialReset = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let initialBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + #expect(initialBootstrap.target.id == WebInspectorTarget.ID("page-old")) + #expect(initialBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 0)) + + _ = await core.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# + ) + let replacementReset = try await modelFeedRequireReset(iterator.next()) + #expect(replacementReset.rawValue == initialReset.rawValue + 1) + + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame" + )) + let replacementSnapshot = try await modelFeedRequireTargetSnapshot(iterator.next()) + let replacementDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + #expect(replacementDocument.targetIdentifier == ProtocolTarget.ID("page-new")) + await modelFeedRespondWithDocument(to: replacementDocument, core: core, nodeID: "new") + let replacementBootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + let replacementCompletion = try await modelFeedRequireBootstrapCompletion(iterator.next()) + let replacementSync = try await modelFeedRequireSynchronization(iterator.next()) + #expect(replacementSnapshot.generation == replacementReset) + #expect(replacementBootstrap.generation == replacementReset) + #expect(replacementBootstrap.target.id == WebInspectorTarget.ID("page-new")) + #expect(replacementBootstrap.documentEpoch == ModelDocumentEpoch(rawValue: 0)) + #expect(replacementCompletion.generation == replacementReset) + #expect(replacementSync.generation == replacementReset) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func pendingDOMBootstrapReplyFromSupersededBindingCannotPublish() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + let oldDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 0 + ) + var iterator = feed.records.makeAsyncIterator() + let initialReset = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + _ = await core.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-old"}}"# + ) + let replacementReset = try await modelFeedRequireReset(iterator.next()) + #expect(replacementReset.rawValue == initialReset.rawValue + 1) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame" + )) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let newDocument = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + #expect(newDocument.targetIdentifier == ProtocolTarget.ID("page-new")) + + await modelFeedRespondWithDocument(to: oldDocument, core: core, nodeID: "stale-old") + await modelFeedRespondWithDocument(to: newDocument, core: core, nodeID: "fresh-new") + let bootstrap = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + #expect(bootstrap.generation == replacementReset) + #expect(bootstrap.target.id == WebInspectorTarget.ID("page-new")) + #expect(bootstrap.root.id == DOM.Node.ID("fresh-new")) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + try await feed.close() + #expect(try await iterator.next() == nil) + await core.close() +} + +@Test +func terminatedDOMFeedCannotAdvanceBootstrapQueue() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-a", + type: "frame", + frameID: "frame-a", + parentFrameID: "main-frame" + )) + var feed: ConnectionModelFeed? = try await core.openModelFeed( + configuredDomains: [.dom] + ) + let records = try #require(feed).records + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + feed = nil + + await modelFeedRespondWithDocument(to: getDocument, core: core) + #expect(await core.terminalCause == .modelFeedFailure(.consumerTerminated)) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(try await modelFeedDOMGetDocumentMessages(backend).map(\.targetIdentifier) == [ + ProtocolTarget.ID("page-main"), + ]) + var iterator = records.makeAsyncIterator() + #expect(try await iterator.next() == nil) +} + +@Test +func requiredDOMBootstrapFailureTerminatesTheFeed() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + await modelFeedRespond( + to: getDocument, + core: core, + errorMessage: "document rejected" + ) + + let expectedError = ConnectionModelFeedError.bootstrapFailed( + domain: .dom, + message: "document rejected" + ) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(await core.terminalCause == .modelFeedFailure(expectedError)) + await #expect(throws: expectedError) { + try await iterator.next() + } + #expect(await backend.isDetached()) + _ = feed +} + +@Test +func requiredDOMRefreshFailurePreservesTypedDomainInMailbox() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom], + targetID: "page-main" + ) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + _ = try await modelFeedRequireDOMBootstrapSnapshot(iterator.next()) + _ = try await modelFeedRequireBootstrapCompletion(iterator.next()) + _ = try await modelFeedRequireSynchronization(iterator.next()) + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + )) + let invalidation = try await modelFeedRequireDOMDocumentInvalidation(iterator.next()) + #expect(invalidation.documentEpoch == ModelDocumentEpoch(rawValue: 1)) + let refresh = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + await modelFeedRespond( + to: refresh, + core: core, + errorMessage: "refresh rejected" + ) + + let expectedError = ConnectionModelFeedError.bootstrapFailed( + domain: .dom, + message: "refresh rejected" + ) + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + #expect(await core.terminalCause == .modelFeedFailure(expectedError)) + await #expect(throws: expectedError) { + try await iterator.next() + } + #expect(await backend.isDetached()) + _ = feed +} + +@Test +func malformedRequiredDOMBootstrapReplyTerminatesWithoutTaskLeak() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + let commandID = try modelFeedMessageID(getDocument.message) + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"id":\#(commandID),"result":{"root":{"nodeId":"incomplete"}}}"# + )) + + await #expect(throws: WebInspectorProxyError.self) { + try await core.waitUntilClosed() + } + guard case let .protocolViolation(message) = await core.terminalCause else { + Issue.record("Expected malformed bootstrap data to terminate as a protocol violation.") + return + } + #expect(message.contains("Failed to decode DOM.getDocument reply")) + #expect(await core.pendingReplyPurposes().isEmpty) + await #expect(throws: WebInspectorProxyError.self) { + try await iterator.next() + } + #expect(await backend.isDetached()) + _ = feed +} + +@Test +func closingDOMModelFeedCancelsAndAwaitsPendingBootstrap() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + _ = try await backend.waitForTargetMessage(method: "DOM.getDocument") + var iterator = feed.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(iterator.next()) + _ = try await modelFeedRequireTargetSnapshot(iterator.next()) + + try await feed.close() + #expect(await core.pendingReplyPurposes().isEmpty) + #expect(try await iterator.next() == nil) + #expect(await backend.isDetached() == false) + + let replacement = try await core.openModelFeed(configuredDomains: []) + var replacementIterator = replacement.records.makeAsyncIterator() + _ = try await modelFeedRequireReset(replacementIterator.next()) + _ = try await modelFeedRequireTargetSnapshot(replacementIterator.next()) + _ = try await modelFeedRequireSynchronization(replacementIterator.next()) + try await replacement.close() + #expect(try await replacementIterator.next() == nil) + await core.close() +} + +@Test +func closingModelFeedDoesNotReadmitDOMBootstrapDuringCommit() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom, .network], + targetID: "page-old" + ) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame", + isProvisional: true + )) + + let closeTask = Task { + try await feed.close() + } + _ = try await backend.waitForTargetMessage(method: "Network.disable") + _ = await core.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# + ) + try await closeTask.value + + #expect(try await modelFeedDOMGetDocumentMessages(backend).map(\.targetIdentifier) == [ + ProtocolTarget.ID("page-old"), + ]) + #expect(await core.pendingReplyPurposes().isEmpty) + await core.close() +} + +@Test +func rollingBackModelFeedDoesNotReadmitDOMBootstrapDuringCommit() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-old", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.dom, .network, .console] + ) + } + let getDocument = try await backend.waitForTargetMessage(method: "DOM.getDocument") + await modelFeedRespondWithDocument(to: getDocument, core: core) + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-new", + type: "page", + frameID: "main-frame", + isProvisional: true + )) + let consoleEnable = try await backend.waitForTargetMessage(method: "Console.enable") + await modelFeedRespond( + to: consoleEnable, + core: core, + errorMessage: "console rejected" + ) + _ = try await backend.waitForTargetMessage(method: "Network.disable") + + _ = await core.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# + ) + await #expect(throws: ConnectionModelFeedError.bootstrapFailed( + domain: .console, + message: "console rejected" + )) { + try await openTask.value + } + #expect(try await modelFeedDOMGetDocumentMessages(backend).map(\.targetIdentifier) == [ + ProtocolTarget.ID("page-old"), + ]) + #expect(await core.pendingReplyPurposes().isEmpty) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + await core.close() +} + +@Test +func terminalDuringModelFeedRollbackCompletesWithoutLeakingClaim() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network, .console] + ) + } + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + let consoleEnable = try await backend.waitForTargetMessage(method: "Console.enable") + await modelFeedRespond( + to: consoleEnable, + core: core, + errorMessage: "console rejected" + ) + _ = try await backend.waitForTargetMessage(method: "Network.disable") + + let fatalHandoff = try #require(core.failFromNativeCallback("fatal during rollback")) + await fatalHandoff.value + await #expect(throws: WebInspectorScopeError.self) { + try await openTask.value + } + #expect(await core.terminalCause == .fatal("fatal during rollback")) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + #expect(await backend.isDetached()) +} + +@Test +func explicitCloseDuringModelFeedRollbackLetsTerminalOwnerRetireRegistration() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network, .console] + ) + } + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + let consoleEnable = try await backend.waitForTargetMessage(method: "Console.enable") + await modelFeedRespond( + to: consoleEnable, + core: core, + errorMessage: "console rejected" + ) + _ = try await backend.waitForTargetMessage(method: "Network.disable") + + await core.close() + + await #expect(throws: ConnectionModelFeedError.bootstrapFailed( + domain: .console, + message: "console rejected" + )) { + try await openTask.value + } + #expect(await core.terminalCause == .explicitClose) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + #expect(await backend.isDetached()) +} + +@Test +func modelFeedRollbackDisableRejectionTerminatesInsteadOfReusingEnabledState() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.network, .console] + ) + } + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + let consoleEnable = try await backend.waitForTargetMessage(method: "Console.enable") + await modelFeedRespond( + to: consoleEnable, + core: core, + errorMessage: "console rejected" + ) + let networkDisable = try await backend.waitForTargetMessage(method: "Network.disable") + await modelFeedRespond( + to: networkDisable, + core: core, + errorMessage: "disable rejected" + ) + + do { + _ = try await openTask.value + Issue.record("Expected activation and rollback cleanup to fail.") + } catch let error as WebInspectorScopeError { + #expect(error.operationError as? ConnectionModelFeedError == .bootstrapFailed( + domain: .console, + message: "console rejected" + )) + #expect(error.cleanupError as? WebInspectorProxyError == .commandRejected( + method: "Network.disable", + message: "disable rejected" + )) + } catch { + Issue.record("Expected WebInspectorScopeError, got \(error).") + } + guard case let .fatal(message) = await core.terminalCause else { + Issue.record("Expected rollback cleanup failure to terminate the connection.") + return + } + #expect(message.contains("Failed to release model feed capabilities")) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + #expect(await backend.isDetached()) + await #expect(throws: WebInspectorProxyError.self) { + try await core.openModelFeed(configuredDomains: []) + } +} + +@Test +func modelFeedCloseDisableRejectionPoisonsMailboxAndTerminatesConnection() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.network], + targetID: "page-main" + ) + var iterator = feed.records.makeAsyncIterator() + _ = try await iterator.next() + _ = try await iterator.next() + + let closeTask = Task { + try await feed.close() + } + let disable = try await backend.waitForTargetMessage(method: "Network.disable") + await modelFeedRespond( + to: disable, + core: core, + errorMessage: "disable rejected" + ) + let expectedError = WebInspectorProxyError.commandRejected( + method: "Network.disable", + message: "disable rejected" + ) + await #expect(throws: expectedError) { + try await closeTask.value + } + await #expect(throws: expectedError) { + try await iterator.next() + } + + guard case let .fatal(message) = await core.terminalCause else { + Issue.record("Expected model feed close cleanup failure to terminate the connection.") + return + } + #expect(message.contains("Failed to release model feed capabilities")) + #expect(await modelFeedAllCapabilityLeaseOwners(core).isEmpty) + #expect(await backend.isDetached()) + try await feed.close() +} + +@Test +func modelBindingCommandWaitsForInitialSynchronizationBeforeWire() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + let openTask = Task { + try await core.openModelFeed( + configuredDomains: [.dom, .network] + ) + } + let bootstrap = try await backend.waitForTargetMessage(method: "DOM.getDocument") + let networkEnable = try await backend.waitForTargetMessage(method: "Network.enable") + await modelFeedRespond(to: networkEnable, core: core) + let feed = try await openTask.value + let generation = try await core.pageGeneration() + let authorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation + ) + + let commandTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) + #expect(await backend.sentTargetMessages().allSatisfy { + (try? modelFeedMessageMethod($0.message)) != "Page.reload" + }) + + await modelFeedRespondWithDocument(to: bootstrap, core: core) + let reload = try await backend.waitForTargetMessage(method: "Page.reload") + await modelFeedRespond(to: reload, core: core) + _ = try await commandTask.value + await core.waitForModelCommandOwnerCountForTesting(0) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Network.enable"] + ) + await core.close() +} + +@Test +func documentRefreshInvalidatesDocumentCommandsButPreservesBindingCommands() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.css, .network], + targetID: "page-main" + ) + let generation = try await core.pageGeneration() + let oldDocument = ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("page-main"), + epoch: ModelDocumentEpoch(rawValue: 0) + ) + let documentAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation, + document: oldDocument + ) + let bindingAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation, + document: oldDocument + ) + let baseline = await backend.sentTargetMessages().count + + let domTask = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: documentAuthorization + )) + } + let cssTask = Task { + try await core.send(modelFeedCommand( + domain: .css, + method: "CSS.getMatchedStylesForNode", + authority: documentAuthorization + )) + } + let networkTask = Task { + try await core.send(modelFeedCommand( + domain: .network, + method: "Network.getResponseBody", + authority: bindingAuthorization + )) + } + let domMessage = try await backend.waitForTargetMessage( + method: "DOM.querySelector", + after: baseline + ) + let cssMessage = try await backend.waitForTargetMessage( + method: "CSS.getMatchedStylesForNode", + after: baseline + ) + let networkMessage = try await backend.waitForTargetMessage( + method: "Network.getResponseBody", + after: baseline + ) + + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + )) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await domTask.value + } + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await cssTask.value + } + await modelFeedRespond(to: domMessage, core: core) + await modelFeedRespond(to: cssMessage, core: core) + await modelFeedRespond(to: networkMessage, core: core) + _ = try await networkTask.value + + let countBeforeOldAuthority = await backend.sentTargetMessages().count + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + _ = try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: documentAuthorization + )) + } + #expect(await backend.sentTargetMessages().count == countBeforeOldAuthority) + + let pageTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: bindingAuthorization + )) + } + let pageMessage = try await backend.waitForTargetMessage( + method: "Page.reload", + after: countBeforeOldAuthority + ) + await modelFeedRespond(to: pageMessage, core: core) + _ = try await pageTask.value + + let freshAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation, + document: ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("page-main"), + epoch: ModelDocumentEpoch(rawValue: 1) + ) + ) + let freshDOMTask = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: freshAuthorization + )) + } + let freshCSSTask = Task { + try await core.send(modelFeedCommand( + domain: .css, + method: "CSS.getMatchedStylesForNode", + authority: freshAuthorization + )) + } + let freshPickerTask = Task { + try await core.send(modelFeedCommand( + domain: .inspector, + method: "Inspector.setInspectModeEnabled", + authority: freshAuthorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(3) + let beforeFreshBootstrap = await backend.sentTargetMessages().count + let refreshBootstrap = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + after: baseline + ) + #expect(await backend.sentTargetMessages().count == beforeFreshBootstrap) + await modelFeedRespondWithDocument(to: refreshBootstrap, core: core, nodeID: "fresh") + let freshDOMMessage = try await backend.waitForTargetMessage( + method: "DOM.querySelector", + after: beforeFreshBootstrap + ) + let freshCSSMessage = try await backend.waitForTargetMessage( + method: "CSS.getMatchedStylesForNode", + after: beforeFreshBootstrap + ) + let freshPickerMessage = try await backend.waitForTargetMessage( + method: "Inspector.setInspectModeEnabled", + after: beforeFreshBootstrap + ) + await modelFeedRespond(to: freshDOMMessage, core: core) + await modelFeedRespond(to: freshCSSMessage, core: core) + await modelFeedRespond(to: freshPickerMessage, core: core) + _ = try await freshDOMTask.value + _ = try await freshCSSTask.value + _ = try await freshPickerTask.value + await core.waitForModelCommandOwnerCountForTesting(0) + + try await modelFeedCloseSuccessfully( + feed, + core: core, + backend: backend, + targetID: "page-main", + enableMethods: ["Page.enable", "CSS.enable", "Network.enable"] + ) + await core.close() +} + +@Test +func modelAuthorityRejectsForeignUnconfiguredAndConnectionOwnedCommandsWithoutWire() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let authorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration(), + document: ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("page-main"), + epoch: ModelDocumentEpoch(rawValue: 0) + ) + ) + let baseline = await backend.sentTargetMessages().count + + await #expect(throws: WebInspectorProxyError.connectionInUse) { + _ = try await core.send(ProtocolCommand( + domain: .dom, + method: "DOM.enable", + routing: .octopus(pageTarget: nil) + )) + } + await #expect(throws: ConnectionModelCommandError.domainNotConfigured(.dom)) { + _ = try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: authorization + )) + } + await #expect(throws: ConnectionModelCommandError.internalCommand( + domain: .network, + method: "Network.enable" + )) { + _ = try await core.send(modelFeedCommand( + domain: .network, + method: "Network.enable", + authority: authorization + )) + } + + let foreignCore = ConnectionCore(backend: FakeTransportBackend(), responseTimeout: nil) + await #expect(throws: ConnectionModelCommandError.notActive) { + _ = try await foreignCore.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + )) + } + #expect(await backend.sentTargetMessages().count == baseline) + + try await feed.close() + await #expect(throws: ConnectionModelCommandError.notActive) { + _ = try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + )) + } + await foreignCore.close() + await core.close() +} + +@Test +func modelPageHandlePropagatesAuthorizationThroughTypedDomainDispatch() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let proxy = try await WebInspectorProxy(transport: core) + let modelPage = WebInspectorPage( + proxy: proxy, + commandAuthorization: ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + ) + + let commandTask = Task { + try await modelPage.page.reload() + } + let reload = try await backend.waitForTargetMessage(method: "Page.reload") + await modelFeedRespond(to: reload, core: core) + try await commandTask.value + + try await feed.close() + await proxy.close() +} + +@Test +func modelCommandCancellationAndFeedCloseDrainAllCommandOwnersBeforeDisable() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await modelFeedOpenSuccessfully( + core: core, + backend: backend, + configuredDomains: [.dom, .network], + targetID: "page-main" + ) + let generation = try await core.pageGeneration() + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: "page-main", + message: #"{"method":"DOM.documentUpdated","params":{}}"# + )) + let documentAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation, + document: ConnectionModelCommandAuthorization.Document( + targetID: WebInspectorTarget.ID("page-main"), + epoch: ModelDocumentEpoch(rawValue: 1) + ) + ) + let bindingAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: generation + ) + + let cancelledTask = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: documentAuthorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) + cancelledTask.cancel() + await #expect(throws: CancellationError.self) { + try await cancelledTask.value + } + await core.waitForModelCommandOwnerCountForTesting(0) + #expect(await core.modelCommandReadinessWaiterCountForTesting() == 0) + + let waitingTask = Task { + try await core.send(modelFeedCommand( + domain: .dom, + method: "DOM.querySelector", + authority: documentAuthorization + )) + } + let pendingTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: bindingAuthorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) + _ = try await backend.waitForTargetMessage(method: "Page.reload") + let closeTask = Task { + try await feed.close() + } + let disable = try await backend.waitForTargetMessage(method: "Network.disable") + await #expect(throws: ConnectionModelCommandError.notActive) { + try await waitingTask.value + } + await #expect(throws: ConnectionModelCommandError.notActive) { + try await pendingTask.value + } + #expect(await core.modelCommandOwnerCountForTesting() == 0) + #expect(await core.modelCommandReadinessWaiterCountForTesting() == 0) + await modelFeedRespond(to: disable, core: core) + try await closeTask.value + await core.close() +} + +@Test +func retargetFailsModelCommandsForOldMainAndFrameBinding() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "frame-child", + type: "frame", + frameID: "child-frame", + parentFrameID: "main-frame" + )) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-next", + type: "page", + frameID: "main-frame", + isProvisional: true + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let authorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + let baseline = await backend.sentTargetMessages().count + let mainTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization, + routing: .target(ProtocolTarget.ID("page-main")) + )) + } + let frameTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization, + routing: .target(ProtocolTarget.ID("frame-child")) + )) + } + _ = try await backend.waitForTargetMessage( + method: "Page.reload", + ordinal: 0, + after: baseline + ) + _ = try await backend.waitForTargetMessage( + method: "Page.reload", + ordinal: 1, + after: baseline + ) + + _ = await core.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"# + ) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await mainTask.value + } + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await frameTask.value + } + #expect(await core.modelCommandOwnerCountForTesting() == 0) + #expect(await core.snapshot().pendingTargetReplyKeys.isEmpty) + + try await feed.close() + await core.close() +} + +@Test +func generationReadinessWaiterDoesNotChaseReplacementSynchronization() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: [.dom]) + let oldBootstrap = try await backend.waitForTargetMessage(method: "DOM.getDocument") + let oldAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + let oldTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: oldAuthorization + )) + } + await core.waitForModelCommandReadinessWaiterCountForTesting(1) + + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-next", + type: "page", + frameID: "main-frame", + isProvisional: true + )) + _ = await core.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"# + ) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await oldTask.value + } + await modelFeedRespondWithDocument(to: oldBootstrap, core: core, nodeID: "late") + + let newBootstrap = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + ordinal: 1 + ) + await modelFeedRespondWithDocument(to: newBootstrap, core: core, nodeID: "new") + #expect(await backend.sentTargetMessages().allSatisfy { + (try? modelFeedMessageMethod($0.message)) != "Page.reload" + }) + + let newAuthorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + let newTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: newAuthorization + )) + } + let reload = try await backend.waitForTargetMessage(method: "Page.reload") + await modelFeedRespond(to: reload, core: core) + _ = try await newTask.value + + try await feed.close() + await core.close() +} + +@Test +func terminalCloseDrainsPendingModelCommandTasks() async throws { + let backend = FakeTransportBackend() + let core = ConnectionCore(backend: backend, responseTimeout: nil) + _ = await core.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + let feed = try await core.openModelFeed(configuredDomains: []) + let authorization = ConnectionModelCommandAuthorization( + feedID: feed.id, + generation: try await core.pageGeneration() + ) + let commandTask = Task { + try await core.send(modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + )) + } + _ = try await backend.waitForTargetMessage(method: "Page.reload") + + await core.close() + + await #expect(throws: TransportSession.Error.transportClosed) { + try await commandTask.value + } + #expect(await core.modelCommandOwnerCountForTesting() == 0) + #expect(await core.modelCommandReadinessWaiterCountForTesting() == 0) + #expect(await backend.isDetached()) +} + +@Test +func readinessWaitingModelCommandRunnerDoesNotKeepCoreAlive() async throws { + let backend = FakeTransportBackend() + var core: ConnectionCore? = ConnectionCore(backend: backend, responseTimeout: nil) + weak let weakCore = core + _ = await core?.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + var feed: ConnectionModelFeed? = try await core?.openModelFeed( + configuredDomains: [.dom] + ) + _ = try await backend.waitForTargetMessage(method: "DOM.getDocument") + let authorization = ConnectionModelCommandAuthorization( + feedID: try #require(feed?.id), + generation: try await #require(core).pageGeneration() + ) + feed = nil + + let runner = try await #require(core).startModelCommandForTesting( + modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + ), + authorization: authorization + ) + await core?.waitForModelCommandReadinessWaiterCountForTesting(1) + + core = nil + + #expect(weakCore == nil) + await #expect(throws: TransportSession.Error.transportClosed) { + try await runner.value + } +} + +@Test +func replyWaitingModelCommandRunnerDoesNotKeepCoreAlive() async throws { + let backend = FakeTransportBackend() + var core: ConnectionCore? = ConnectionCore(backend: backend, responseTimeout: nil) + weak let weakCore = core + _ = await core?.receiveRootMessage(modelFeedTargetCreatedMessage( + id: "page-main", + type: "page", + frameID: "main-frame" + )) + + var feed: ConnectionModelFeed? = try await core?.openModelFeed( + configuredDomains: [] + ) + let authorization = ConnectionModelCommandAuthorization( + feedID: try #require(feed?.id), + generation: try await #require(core).pageGeneration() + ) + feed = nil + + let runner = try await #require(core).startModelCommandForTesting( + modelFeedCommand( + domain: .page, + method: "Page.reload", + authority: authorization + ), + authorization: authorization + ) + _ = try await backend.waitForTargetMessage(method: "Page.reload") + + core = nil + + #expect(weakCore == nil) + await #expect(throws: TransportSession.Error.transportClosed) { + try await runner.value + } +} + +private func modelFeedCommand( + domain: ProtocolDomain, + method: String, + authority: ConnectionModelCommandAuthorization, + routing: ProtocolCommand.Routing = .octopus(pageTarget: nil) +) -> ProtocolCommand { + ProtocolCommand( + domain: domain, + method: method, + routing: routing, + authority: .modelFeed(authority) + ) +} + +private func modelFeedExpectedEnableMethods( + _ configuredDomains: Set +) -> [String] { + var seenDomains: Set = [] + var methods: [String] = [] + for domain in ModelDomain.ordered(configuredDomains) { + let capabilityDomains = ConnectionCapabilityActivationPlan.domains( + for: domain.capabilityDependencies, + includePageDependencyForCSS: true + ) + for dependency in capabilityDomains where dependency != .dom { + guard seenDomains.insert(dependency).inserted else { + continue + } + methods.append("\(dependency.rawValue).enable") + } + } + return methods +} + +private func modelFeedAcquireDirectElementPickerScope( + core: ConnectionCore +) async throws -> WebInspectorProxyEventScope { + try await core.acquireEventScope( + route: .currentPage, + targetID: .currentPage, + domain: .inspector, + buffering: .bounded(8), + extract: { event in + guard case let .inspector(value) = event else { + return nil + } + return value + } + ) +} + +private func modelFeedCompleteElementPickerAcquisition( + core: ConnectionCore, + backend: FakeTransportBackend, + targetID: String, + after baseline: Int = 0, + expectsInitialization: Bool +) async throws { + let enable = try await backend.waitForTargetMessage( + method: "Inspector.enable", + after: baseline + ) + #expect(enable.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespond(to: enable, core: core) + if expectsInitialization { + let initialized = try await backend.waitForTargetMessage( + method: "Inspector.initialized", + after: baseline + ) + #expect(initialized.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespond(to: initialized, core: core) + } + let activate = try await backend.waitForTargetMessage( + method: "DOM.setInspectModeEnabled", + after: baseline + ) + #expect(activate.targetIdentifier == ProtocolTarget.ID(targetID)) + #expect(try modelFeedElementPickerEnabled(activate.message) == true) + await modelFeedRespond(to: activate, core: core) +} + +private func modelFeedCompleteElementPickerRelease( + core: ConnectionCore, + backend: FakeTransportBackend, + targetID: String, + after baseline: Int +) async throws { + let deactivate = try await backend.waitForTargetMessage( + method: "DOM.setInspectModeEnabled", + after: baseline + ) + #expect(deactivate.targetIdentifier == ProtocolTarget.ID(targetID)) + #expect(try modelFeedElementPickerEnabled(deactivate.message) == false) + await modelFeedRespond(to: deactivate, core: core) + let disable = try await backend.waitForTargetMessage( + method: "Inspector.disable", + after: baseline + ) + #expect(disable.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespond(to: disable, core: core) +} + +private func modelFeedElementPickerEnabled(_ message: String) throws -> Bool { + let object = try JSONSerialization.jsonObject(with: Data(message.utf8)) + let dictionary = try #require(object as? [String: Any]) + let parameters = try #require(dictionary["params"] as? [String: Any]) + return try #require(parameters["enabled"] as? Bool) +} + +private func modelFeedInspectorInspectMessage(objectID: String) -> String { + let data = try! JSONSerialization.data( + withJSONObject: [ + "method": "Inspector.inspect", + "params": [ + "object": [ + "objectId": objectID, + "type": "object", + "subtype": "node", + ], + "hints": [:] as [String: Any], + ] as [String: Any], + ], + options: [.sortedKeys] + ) + return String(decoding: data, as: UTF8.self) +} + +private func modelFeedOpenSuccessfully( + core: ConnectionCore, + backend: FakeTransportBackend, + configuredDomains: Set, + targetID: String +) async throws -> ConnectionModelFeed { + let normalizedDomains = ModelDomain.normalized(configuredDomains) + let enableMethods = modelFeedExpectedEnableMethods(normalizedDomains) + let targetMessageCount = await backend.sentTargetMessages().count + let openTask = Task { + try await core.openModelFeed( + configuredDomains: configuredDomains + ) + } + if normalizedDomains.contains(.dom) { + let message = try await backend.waitForTargetMessage( + method: "DOM.getDocument", + after: targetMessageCount + ) + #expect(message.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespondWithDocument(to: message, core: core) + } + for expectedMethod in enableMethods { + let message = try await backend.waitForTargetMessage( + method: expectedMethod, + after: targetMessageCount + ) + #expect(try modelFeedMessageMethod(message.message) == expectedMethod) + #expect(message.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespond(to: message, core: core) + } + return try await openTask.value +} + +private func modelFeedCloseSuccessfully( + _ feed: ConnectionModelFeed, + core: ConnectionCore, + backend: FakeTransportBackend, + targetID: String, + enableMethods: [String] +) async throws { + let disableMethods = enableMethods.reversed().map { + $0.replacingOccurrences(of: ".enable", with: ".disable") + } + let targetMessageCount = await backend.sentTargetMessages().count + let closeTask = Task { + try await feed.close() + } + for (offset, expectedMethod) in disableMethods.enumerated() { + let message = try await backend.waitForTargetMessage( + ordinal: 0, + after: targetMessageCount + offset + ) + #expect(try modelFeedMessageMethod(message.message) == expectedMethod) + #expect(message.targetIdentifier == ProtocolTarget.ID(targetID)) + await modelFeedRespond(to: message, core: core) + } + try await closeTask.value +} + +private func modelFeedSentTargetMethods( + _ backend: FakeTransportBackend +) async throws -> [String] { + try await backend.sentTargetMessages().compactMap { + let method = try modelFeedMessageMethod($0.message) + return method == "DOM.getDocument" ? nil : method + } +} + +private func modelFeedDOMGetDocumentMessages( + _ backend: FakeTransportBackend +) async throws -> [SentTargetMessage] { + try await backend.sentTargetMessages().filter { + try modelFeedMessageMethod($0.message) == "DOM.getDocument" + } +} + +private func modelFeedAllCapabilityLeaseOwners( + _ core: ConnectionCore +) async -> Set { + await core.capabilityLeaseOwnersForTesting().values.reduce(into: []) { + $0.formUnion($1) + } +} + +private func modelFeedWaitForNoDesiredCapabilityOwners( + _ core: ConnectionCore, + key: ConnectionCapabilityKey +) async -> Bool { + for _ in 0..<1_000 { + if await core.desiredCapabilityLeaseOwnersForTesting()[key]?.isEmpty == true { + return true + } + await Task.yield() + } + return false +} + +private func modelFeedMessageMethod(_ message: String) throws -> String { + let object = try JSONSerialization.jsonObject(with: Data(message.utf8)) + let dictionary = try #require(object as? [String: Any]) + return try #require(dictionary["method"] as? String) +} + +private func modelFeedRespond( + to message: SentTargetMessage, + core: ConnectionCore, + errorMessage: String? = nil +) async { + let messageID = try! modelFeedMessageID(message.message) + let reply: [String: Any] + if let errorMessage { + reply = [ + "id": messageID, + "error": ["message": errorMessage], + ] + } else { + reply = [ + "id": messageID, + "result": [:] as [String: Any], + ] + } + let data = try! JSONSerialization.data(withJSONObject: reply, options: [.sortedKeys]) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: message.targetIdentifier.rawValue, + message: String(decoding: data, as: UTF8.self) + )) +} + +private func modelFeedRespondWithDocument( + to message: SentTargetMessage, + core: ConnectionCore, + nodeID: String = "1" +) async { + let messageID = try! modelFeedMessageID(message.message) + let reply: [String: Any] = [ + "id": messageID, + "result": [ + "root": [ + "nodeId": nodeID, + "nodeType": 9, + "nodeName": "#document", + "localName": "", + "nodeValue": "", + "childNodeCount": 0, + "children": [] as [[String: Any]], + ] as [String: Any], + ] as [String: Any], + ] + let data = try! JSONSerialization.data(withJSONObject: reply, options: [.sortedKeys]) + _ = await core.receiveRootMessage(modelFeedTargetDispatchMessage( + targetID: message.targetIdentifier.rawValue, + message: String(decoding: data, as: UTF8.self) + )) +} + +private struct ModelFeedEventRecord { + let generation: WebInspectorPage.Generation + let sequence: UInt64 + let payload: ModelProtocolEvent +} + +private struct ModelFeedTargetSnapshotRecord { + let generation: WebInspectorPage.Generation + let through: UInt64 + let snapshot: ModelTargetSnapshot +} + +private struct ModelFeedDOMDocumentInvalidationRecord { + let generation: WebInspectorPage.Generation + let sequence: UInt64 + let target: ModelTarget + let documentEpoch: ModelDocumentEpoch +} + +private struct ModelFeedSynchronizationRecord { + let generation: WebInspectorPage.Generation + let through: UInt64 +} + +private struct ModelFeedReplayCompletionRecord { + let generation: WebInspectorPage.Generation + let domain: ModelDomain + let through: UInt64 +} + +private struct ModelFeedDOMBootstrapSnapshotRecord { + let generation: WebInspectorPage.Generation + let sequence: UInt64 + let target: ModelTarget + let documentEpoch: ModelDocumentEpoch + let root: DOM.Node +} + +private struct ModelFeedBootstrapCompletionRecord { + let generation: WebInspectorPage.Generation + let domain: ModelDomain + let through: UInt64 +} + +private enum ModelFeedTestError: Error { + case unexpectedRecord +} + +private func modelFeedRequireReset( + _ record: ConnectionModelFeedRecord? +) throws -> WebInspectorPage.Generation { + guard case let .reset(generation) = try #require(record) else { + Issue.record("Expected model feed reset.") + throw ModelFeedTestError.unexpectedRecord + } + return generation +} + +private func modelFeedRequireTargetSnapshot( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedTargetSnapshotRecord { + guard case let .targetSnapshot(generation, through, snapshot) = try #require(record) else { + Issue.record("Expected model target snapshot.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedTargetSnapshotRecord( + generation: generation, + through: through, + snapshot: snapshot + ) +} + +private func modelFeedRequireDOMDocumentInvalidation( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedDOMDocumentInvalidationRecord { + guard case let .domDocumentInvalidated( + generation, + sequence, + target, + documentEpoch + ) = try #require(record) else { + Issue.record("Expected a DOM document invalidation boundary.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedDOMDocumentInvalidationRecord( + generation: generation, + sequence: sequence, + target: target, + documentEpoch: documentEpoch + ) +} + +private func modelFeedRequireSynchronization( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedSynchronizationRecord { + guard case let .synchronizationComplete(generation, through) = try #require(record) else { + Issue.record("Expected model feed synchronization completion.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedSynchronizationRecord(generation: generation, through: through) +} + +private func modelFeedRequireReplayCompletion( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedReplayCompletionRecord { + guard case let .replayComplete(generation, domain, through) = try #require(record) else { + Issue.record("Expected model feed replay completion.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedReplayCompletionRecord( + generation: generation, + domain: domain, + through: through + ) +} + +private func modelFeedRequireDOMBootstrapSnapshot( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedDOMBootstrapSnapshotRecord { + guard case let .bootstrapSnapshot(generation, domain, sequence, payload) = try #require(record), + domain == .dom, + case let .domDocument(target, documentEpoch, root) = payload else { + Issue.record("Expected a DOM bootstrap snapshot.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedDOMBootstrapSnapshotRecord( + generation: generation, + sequence: sequence, + target: target, + documentEpoch: documentEpoch, + root: root + ) +} + +private func modelFeedRequireBootstrapCompletion( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedBootstrapCompletionRecord { + guard case let .bootstrapComplete(generation, domain, through) = try #require(record) else { + Issue.record("Expected model bootstrap completion.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedBootstrapCompletionRecord( + generation: generation, + domain: domain, + through: through + ) +} + +private func modelFeedRequireEvent( + _ record: ConnectionModelFeedRecord? +) throws -> ModelFeedEventRecord { + guard case let .event(generation, sequence, payload) = try #require(record) else { + Issue.record("Expected model feed event.") + throw ModelFeedTestError.unexpectedRecord + } + return ModelFeedEventRecord( + generation: generation, + sequence: sequence, + payload: payload + ) +} + +private func modelFeedTargetRecord( + id: String, + kind: ProtocolTarget.Kind, + frameID: String?, + parentFrameID: String? = nil, + isProvisional: Bool = false +) -> ProtocolTarget.Record { + ProtocolTarget.Record( + id: ProtocolTarget.ID(id), + kind: kind, + frameID: frameID.map { ProtocolFrame.ID($0) }, + parentFrameID: parentFrameID.map { ProtocolFrame.ID($0) }, + isProvisional: isProvisional + ) +} + +private func modelFeedMessageID(_ message: String) throws -> UInt64 { + let object = try JSONSerialization.jsonObject(with: Data(message.utf8)) + let dictionary = try #require(object as? [String: Any]) + return try #require(dictionary["id"] as? UInt64) +} + +private func modelFeedTargetCreatedMessage( + id: String, + type: String, + frameID: String?, + parentFrameID: String? = nil, + isProvisional: Bool = false +) -> String { + var targetInfo: [String: Any] = [ + "targetId": id, + "type": type, + "isProvisional": isProvisional, + ] + if let frameID { + targetInfo["frameId"] = frameID + } + if let parentFrameID { + targetInfo["parentFrameId"] = parentFrameID + } + let data = try! JSONSerialization.data( + withJSONObject: [ + "method": "Target.targetCreated", + "params": ["targetInfo": targetInfo], + ], + options: [.sortedKeys] + ) + return String(decoding: data, as: UTF8.self) +} + +private func modelFeedTargetDispatchMessage( + targetID: String, + message: String +) -> String { + let data = try! JSONSerialization.data( + withJSONObject: [ + "method": "Target.dispatchMessageFromTarget", + "params": [ + "targetId": targetID, + "message": message, + ], + ], + options: [.sortedKeys] + ) + return String(decoding: data, as: UTF8.self) +} + +private actor ModelFeedArmedMessageParser { + private var shouldBlockNextInvocation = false + private var isBlocked = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func armNextInvocation() { + precondition(!shouldBlockNextInvocation && !isBlocked) + shouldBlockNextInvocation = true + isReleased = false + } + + func parse(_ message: String) async throws -> ParsedProtocolMessage { + guard shouldBlockNextInvocation else { + return try await TransportMessageParser.parse(message) + } + shouldBlockNextInvocation = false + isBlocked = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + if !isReleased { + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + isBlocked = false + return try await TransportMessageParser.parse(message) + } + + func waitUntilBlocked() async { + guard !isBlocked else { + return + } + await withCheckedContinuation { continuation in + if isBlocked { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +private actor ModelFeedAsyncGate { + private var isStarted = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitUntilReleased() async { + isStarted = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + guard !isReleased else { + return + } + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + + func waitUntilStarted() async { + guard !isStarted else { + return + } + await withCheckedContinuation { continuation in + if isStarted { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +private actor ModelFeedProbe { + private(set) var isFinished = false + private var waiters: [CheckedContinuation] = [] + + func finish() { + guard !isFinished else { + return + } + isFinished = true + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + func waitUntilFinished() async { + guard !isFinished else { + return + } + await withCheckedContinuation { continuation in + if isFinished { + continuation.resume() + } else { + waiters.append(continuation) + } + } + } +} + +private final class ModelFeedSynchronousGate: Sendable { + private struct State { + var isBlocked = false + var waiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + private let releaseSemaphore = DispatchSemaphore(value: 0) + + func block() { + let waiters = state.withLock { state in + state.isBlocked = true + let waiters = state.waiters + state.waiters.removeAll() + return waiters + } + for waiter in waiters { + waiter.resume() + } + releaseSemaphore.wait() + } + + func waitUntilBlocked() async { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + guard !state.isBlocked else { + return true + } + state.waiters.append(continuation) + return false + } + if shouldResume { + continuation.resume() + } + } + } + + func release() { + releaseSemaphore.signal() + } +} diff --git a/Tests/WebInspectorProxyKitTests/DomainHandleTests.swift b/Tests/WebInspectorProxyKitTests/DomainHandleTests.swift new file mode 100644 index 00000000..af455322 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/DomainHandleTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import WebInspectorProxyKit + +@Test +func domainHandlesBindCommandsAndEventsToTheirWireDomains() throws { + let domEvent = WebInspectorProxyEvent.dom( + .unknown(RawEvent(domain: "DOM", method: "domProbe")) + ) + let cssEvent = WebInspectorProxyEvent.css( + .unknown(RawEvent(domain: "CSS", method: "cssProbe")) + ) + let networkEvent = WebInspectorProxyEvent.network( + .unknown(RawEvent(domain: "Network", method: "networkProbe")) + ) + let consoleEvent = WebInspectorProxyEvent.console( + Console.TargetedEvent( + event: .unknown(RawEvent(domain: "Console", method: "consoleProbe")), + targetID: WebInspectorTarget.ID("console-target") + ) + ) + let runtimeEvent = WebInspectorProxyEvent.runtime( + .unknown(RawEvent(domain: "Runtime", method: "runtimeProbe")) + ) + + try assertEventDomain( + DOM.self, + commandDomain: .dom, + eventDomain: .dom, + event: domEvent, + crossDomainEvent: cssEvent, + expectedMethod: "domProbe", + rawEvent: { event in + guard case let .unknown(rawEvent) = event else { return nil } + return rawEvent + } + ) + try assertEventDomain( + CSS.self, + commandDomain: .css, + eventDomain: .css, + event: cssEvent, + crossDomainEvent: networkEvent, + expectedMethod: "cssProbe", + rawEvent: { event in + guard case let .unknown(rawEvent) = event else { return nil } + return rawEvent + } + ) + try assertEventDomain( + Network.self, + commandDomain: .network, + eventDomain: .network, + event: networkEvent, + crossDomainEvent: consoleEvent, + expectedMethod: "networkProbe", + rawEvent: { event in + guard case let .unknown(rawEvent) = event else { return nil } + return rawEvent + } + ) + try assertEventDomain( + Console.self, + commandDomain: .console, + eventDomain: .console, + event: consoleEvent, + crossDomainEvent: runtimeEvent, + expectedMethod: "consoleProbe", + rawEvent: { event in + guard case let .unknown(rawEvent) = event else { return nil } + return rawEvent + } + ) + try assertEventDomain( + Runtime.self, + commandDomain: .runtime, + eventDomain: .runtime, + event: runtimeEvent, + crossDomainEvent: domEvent, + expectedMethod: "runtimeProbe", + rawEvent: { event in + guard case let .unknown(rawEvent) = event else { return nil } + return rawEvent + } + ) + + #expect(Page.commandDomain == .page) + #expect(Inspector.commandDomain == .inspector) +} + +private func assertEventDomain( + _ handleType: Handle.Type, + commandDomain: WebInspectorProxyDomain, + eventDomain: WebInspectorProxyEventDomain, + event: WebInspectorProxyEvent, + crossDomainEvent: WebInspectorProxyEvent, + expectedMethod: String, + rawEvent: (Handle.Event) -> RawEvent? +) throws { + #expect(handleType.commandDomain == commandDomain) + #expect(handleType.eventDomain == eventDomain) + + let extractedEvent = try #require(handleType.extractEvent(event)) + let extractedRawEvent = try #require(rawEvent(extractedEvent)) + #expect(extractedRawEvent.method == expectedMethod) + #expect(handleType.extractEvent(crossDomainEvent) == nil) +} diff --git a/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift new file mode 100644 index 00000000..937d81cf --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/NativeAttachmentLifecycleTests.swift @@ -0,0 +1,502 @@ +#if canImport(UIKit) +import Testing +import UIKit +import WebKit +@testable import WebInspectorProxyKit + +@MainActor +@Test +func nativeAttachmentExplicitCloseOrdersReceiverDetachRestoreAndWaiters() async throws { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let recorder = NativeDetachRecorder() + let gate = NativeDetachGate(blocks: true) + let graph = NativeAttachmentTestGraph( + webView: webView, + recorder: recorder, + gate: gate + ) + let closeCompletion = NativeCloseCompletionProbe() + let core = graph.core + + let waitTask = Task { + try await core.waitUntilClosed() + await closeCompletion.finish() + } + await core.waitForCloseWaiterForTesting() + + let closeTask = Task { + await core.close() + } + await gate.waitUntilStarted() + + #expect(recorder.asyncDetachStartedCount == 1) + #expect(recorder.asyncDetachCompletedCount == 0) + #expect(recorder.synchronousDetachCount == 0) + #expect(webView.isInspectable) + #expect(await closeCompletion.isFinished == false) + + // NativeAttachment closes its receiver before beginning asynchronous + // detach. Real callback-shaped delivery is therefore inert while detach + // is still blocked. + let snapshotDuringDetach = await core.snapshot() + graph.backend.emitMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"late-page","type":"page","isProvisional":false}}}"# + ) + graph.backend.emitFatalFailure("late native failure") + #expect(await core.snapshot() == snapshotDuringDetach) + #expect(await core.terminalCause == .explicitClose) + + await gate.release() + await closeTask.value + try await waitTask.value + + #expect(recorder.asyncDetachCompletedCount == 1) + #expect(webView.isInspectable == false) + #expect(await closeCompletion.isFinished) +} + +@MainActor +@Test +func nativeAttachmentWaitsForActiveReceiverDrainBeforeDetachAndRestore() async { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let recorder = NativeDetachRecorder() + let parser = NativeAttachmentMessageParserGate() + let graph = NativeAttachmentTestGraph( + webView: webView, + recorder: recorder, + gate: NativeDetachGate(blocks: false), + parser: parser.parse + ) + + graph.backend.emitMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"late-page","type":"page","isProvisional":false}}}"# + ) + await parser.waitUntilBlocked() + + let closeTask = Task { + await graph.core.close() + } + while graph.receiver.closeWaiterCountForTesting() == 0 { + await Task.yield() + } + + #expect(recorder.asyncDetachStartedCount == 0) + #expect(recorder.asyncDetachCompletedCount == 0) + #expect(webView.isInspectable) + #expect(await graph.core.snapshot().targetsByID.isEmpty) + + await parser.release() + await closeTask.value + + #expect(recorder.asyncDetachStartedCount == 1) + #expect(recorder.asyncDetachCompletedCount == 1) + #expect(webView.isInspectable == false) + #expect(await graph.core.snapshot().targetsByID.isEmpty) +} + +@MainActor +@Test +func droppingNativeAttachmentGraphUsesSynchronousBackstop() async throws { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let recorder = NativeDetachRecorder() + var graph: NativeAttachmentTestGraph? = NativeAttachmentTestGraph( + webView: webView, + recorder: recorder, + gate: NativeDetachGate(blocks: false) + ) + await installPageTarget(in: try #require(graph).core) + var proxy: WebInspectorProxy? = try await WebInspectorProxy( + transport: try #require(graph).core + ) + + weak let weakProxy = proxy + weak let weakCore = graph?.core + weak let weakBackend = graph?.backend + weak let weakReceiver = graph?.receiver + weak let weakAttachment = graph?.attachment + + proxy = nil + graph = nil + await recorder.waitForBackendDeinitialization() + + #expect(weakProxy == nil) + #expect(weakCore == nil) + #expect(weakBackend == nil) + #expect(weakReceiver == nil) + #expect(weakAttachment == nil) + #expect(recorder.asyncDetachStartedCount == 0) + #expect(recorder.asyncDetachCompletedCount == 0) + #expect(recorder.synchronousDetachCount == 1) + #expect(webView.isInspectable == false) +} + +@MainActor +@Test +func nativeAttachmentDoubleCloseDetachesAndRestoresExactlyOnce() async { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let recorder = NativeDetachRecorder() + let graph = NativeAttachmentTestGraph( + webView: webView, + recorder: recorder, + gate: NativeDetachGate(blocks: false) + ) + + await graph.core.close() + + #expect(recorder.asyncDetachStartedCount == 1) + #expect(recorder.asyncDetachCompletedCount == 1) + #expect(webView.isInspectable == false) + + // If a second close tried to release the lease again, it would overwrite + // this intervening value with the original false value. + webView.isInspectable = true + await graph.core.close() + + #expect(recorder.asyncDetachStartedCount == 1) + #expect(recorder.asyncDetachCompletedCount == 1) + #expect(recorder.synchronousDetachCount == 0) + #expect(webView.isInspectable) + webView.isInspectable = false +} + +@MainActor +@Test +func nativeAttachmentsShareOneInspectabilityLeasePerWebView() async { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let firstRecorder = NativeDetachRecorder() + let secondRecorder = NativeDetachRecorder() + let first = NativeAttachmentTestGraph( + webView: webView, + recorder: firstRecorder, + gate: NativeDetachGate(blocks: false) + ) + let second = NativeAttachmentTestGraph( + webView: webView, + recorder: secondRecorder, + gate: NativeDetachGate(blocks: false) + ) + + await first.core.close() + + #expect(firstRecorder.asyncDetachCompletedCount == 1) + #expect(secondRecorder.asyncDetachCompletedCount == 0) + #expect(webView.isInspectable) + + await second.core.close() + + #expect(secondRecorder.asyncDetachCompletedCount == 1) + #expect(webView.isInspectable == false) +} + +@MainActor +@Test +func explicitlyClosedNativeAttachmentGraphDeallocatesWithoutCycles() async throws { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + let recorder = NativeDetachRecorder() + var graph: NativeAttachmentTestGraph? = NativeAttachmentTestGraph( + webView: webView, + recorder: recorder, + gate: NativeDetachGate(blocks: false) + ) + await installPageTarget(in: try #require(graph).core) + var proxy: WebInspectorProxy? = try await WebInspectorProxy( + transport: try #require(graph).core + ) + + weak let weakProxy = proxy + weak let weakCore = graph?.core + weak let weakBackend = graph?.backend + weak let weakReceiver = graph?.receiver + weak let weakAttachment = graph?.attachment + + await proxy?.close() + proxy = nil + graph = nil + await recorder.waitForBackendDeinitialization() + + #expect(weakProxy == nil) + #expect(weakCore == nil) + #expect(weakBackend == nil) + #expect(weakReceiver == nil) + #expect(weakAttachment == nil) + #expect(recorder.asyncDetachStartedCount == 1) + #expect(recorder.asyncDetachCompletedCount == 1) + #expect(recorder.synchronousDetachCount == 0) + #expect(webView.isInspectable == false) +} + +@MainActor +@Test +func nativeReceiverCallbacksRemainDisabledAfterClose() async { + let webView = WKWebView(frame: .zero) + let graph = NativeAttachmentTestGraph( + webView: webView, + recorder: NativeDetachRecorder(), + gate: NativeDetachGate(blocks: false) + ) + + await graph.core.close() + let closedSnapshot = await graph.core.snapshot() + + graph.backend.emitMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"late-page","type":"page","isProvisional":false}}}"# + ) + graph.backend.emitFatalFailure("late failure") + + #expect(await graph.core.snapshot() == closedSnapshot) + #expect(await graph.core.terminalCause == .explicitClose) +} + +@MainActor +private struct NativeAttachmentTestGraph { + let receiver: TransportReceiver + let backend: FakeNativeAttachmentBackend + let attachment: NativeAttachment + let core: ConnectionCore + + init( + webView: WKWebView, + recorder: NativeDetachRecorder, + gate: NativeDetachGate, + parser: @escaping ConnectionCore.MessageParser = { + try await TransportMessageParser.parse($0) + } + ) { + let receiver = TransportReceiver() + let backend = FakeNativeAttachmentBackend( + receiver: receiver, + recorder: recorder, + gate: gate + ) + let page = NativeInspectablePage(webView: webView) + let attachment = NativeAttachment( + receiver: receiver, + backend: backend, + page: page + ) + let core = ConnectionCore( + backend: backend, + messageParser: parser, + closeAction: { + await attachment.close() + } + ) + receiver.setCore(core) + + self.receiver = receiver + self.backend = backend + self.attachment = attachment + self.core = core + } +} + +@MainActor +private final class FakeNativeAttachmentBackend: NativeAttachmentBackend { + private nonisolated let receiver: TransportReceiver + private nonisolated let recorder: NativeDetachRecorder + private nonisolated let gate: NativeDetachGate + + init( + receiver: TransportReceiver, + recorder: NativeDetachRecorder, + gate: NativeDetachGate + ) { + self.receiver = receiver + self.recorder = recorder + self.gate = gate + } + + nonisolated func sendJSONString(_ message: String) async throws { + _ = message + } + + nonisolated func detach() async { + await recorder.recordAsyncDetachStarted() + await gate.beginDetach() + await recorder.recordAsyncDetachCompleted() + } + + func detachSynchronously() { + recorder.recordSynchronousDetach() + } + + func emitMessage(_ message: String) { + receiver.receive(message) + } + + func emitFatalFailure(_ message: String) { + receiver.fail(message) + } + + isolated deinit { + recorder.recordBackendDeinitialized() + } +} + +@MainActor +private final class NativeDetachRecorder { + private(set) var asyncDetachStartedCount = 0 + private(set) var asyncDetachCompletedCount = 0 + private(set) var synchronousDetachCount = 0 + private(set) var backendDeinitializationCount = 0 + private var backendDeinitializationWaiters: [CheckedContinuation] = [] + + func recordAsyncDetachStarted() { + asyncDetachStartedCount += 1 + } + + func recordAsyncDetachCompleted() { + asyncDetachCompletedCount += 1 + } + + func recordSynchronousDetach() { + synchronousDetachCount += 1 + } + + func recordBackendDeinitialized() { + backendDeinitializationCount += 1 + let waiters = backendDeinitializationWaiters + backendDeinitializationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + func waitForBackendDeinitialization() async { + guard backendDeinitializationCount == 0 else { + return + } + await withCheckedContinuation { continuation in + if backendDeinitializationCount > 0 { + continuation.resume() + } else { + backendDeinitializationWaiters.append(continuation) + } + } + } +} + +private actor NativeDetachGate { + private let blocks: Bool + private var didStart = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + init(blocks: Bool) { + self.blocks = blocks + } + + func beginDetach() async { + didStart = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + + guard blocks, !isReleased else { + return + } + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + + func waitUntilStarted() async { + guard !didStart else { + return + } + await withCheckedContinuation { continuation in + if didStart { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +private actor NativeCloseCompletionProbe { + private(set) var isFinished = false + + func finish() { + isFinished = true + } +} + +private actor NativeAttachmentMessageParserGate { + private var isBlocked = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func parse(_ message: String) async throws -> ParsedProtocolMessage { + isBlocked = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + if !isReleased { + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + return try await TransportMessageParser.parse(message) + } + + func waitUntilBlocked() async { + guard !isBlocked else { + return + } + await withCheckedContinuation { continuation in + if isBlocked { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +@MainActor +private func installPageTarget(in core: ConnectionCore) async { + await core.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"# + ) +} + +#endif diff --git a/Tests/WebInspectorProxyKitTests/NativeInspectablePageTests.swift b/Tests/WebInspectorProxyKitTests/NativeInspectablePageTests.swift index 2951596d..20e8d3ca 100644 --- a/Tests/WebInspectorProxyKitTests/NativeInspectablePageTests.swift +++ b/Tests/WebInspectorProxyKitTests/NativeInspectablePageTests.swift @@ -43,6 +43,21 @@ func overlappingNativeInspectablePagesRestoreOnlyAfterLastOwner() { #expect(webView.isInspectable == false) } +@MainActor +@Test +func droppingNativeInspectablePageRestoresInspectability() { + let webView = WKWebView(frame: .zero) + webView.isInspectable = false + var page: NativeInspectablePage? = NativeInspectablePage(webView: webView) + + #expect(webView.isInspectable) + + page = nil + + #expect(page == nil) + #expect(webView.isInspectable == false) +} + @MainActor @Test func nativeInspectablePageReloadFailsWhenWebViewIsUnavailable() { diff --git a/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift b/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift new file mode 100644 index 00000000..b56b237a --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/ReplyPromiseTests.swift @@ -0,0 +1,226 @@ +import Testing +import WebInspectorTestSupport +@testable import WebInspectorProxyKit + +private enum ReplyPromiseTestError: Error, Equatable { + case duplicate +} + +private final class ReplyPromiseLifetimeToken: Sendable {} + +@Test +func replyPromiseReplaysFulfillmentBeforeWait() async throws { + let promise = ReplyPromise() + + #expect(promise.fulfill(.success(41))) + #expect(try await promise.value() == 41) + #expect(try await promise.value() == 41) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseResumesWaiterAfterFulfillment() async throws { + let promise = ReplyPromise() + let waiter = Task { + try await promise.value() + } + + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + #expect(promise.fulfill(.success(42))) + #expect(try await waiter.value == 42) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseResumesConcurrentWaitersWithOneTerminalResult() async throws { + let promise = ReplyPromise() + let first = Task { + try await promise.value() + } + let second = Task { + try await promise.value() + } + + #expect(await waitForReplyPromiseWaiterCount(2, in: promise)) + #expect(promise.fulfill(.success(43))) + #expect(try await first.value == 43) + #expect(try await second.value == 43) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseKeepsFirstFulfillment() async throws { + let promise = ReplyPromise() + + #expect(promise.fulfill(.success(44))) + #expect(!promise.fulfill(.failure(ReplyPromiseTestError.duplicate))) + #expect(try await promise.value() == 44) +} + +@Test +func replyPromiseReplaysFirstTerminalFailure() async { + let promise = ReplyPromise() + + #expect(promise.fulfill(.failure(ReplyPromiseTestError.duplicate))) + #expect(!promise.fulfill(.success(44))) + await #expect(throws: ReplyPromiseTestError.duplicate) { + try await promise.value() + } + await #expect(throws: ReplyPromiseTestError.duplicate) { + try await promise.value() + } + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseUnresolvedWaitObservesPreexistingCancellation() async throws { + let promise = ReplyPromise() + let startGate = WebInspectorTestGate() + let waiter = Task { + await startGate.waiter.wait() + return try await promise.value() + } + + waiter.cancel() + await #expect(throws: CancellationError.self) { + try await waiter.value + } + #expect(promise.bookkeepingCountForTesting() == 0) + #expect(promise.fulfill(.success(45))) + #expect(try await promise.value() == 45) +} + +@Test +func replyPromiseCleanupWaitIgnoresCallerCancellationUntilTerminal() async throws { + let promise = ReplyPromise() + let startGate = WebInspectorTestGate() + let waiter = Task { + await startGate.waiter.wait() + return try await promise.valueIgnoringCancellation() + } + + waiter.cancel() + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + #expect(promise.fulfill(.success(46))) + #expect(try await waiter.value == 46) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseCancelledWaiterDoesNotPoisonLaterFulfillment() async throws { + let promise = ReplyPromise() + let cancelledWaiter = Task { + try await promise.value() + } + + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + cancelledWaiter.cancel() + await #expect(throws: CancellationError.self) { + try await cancelledWaiter.value + } + #expect(promise.bookkeepingCountForTesting() == 0) + + let laterWaiter = Task { + try await promise.value() + } + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + #expect(promise.fulfill(.success(45))) + #expect(try await laterWaiter.value == 45) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseTerminalResultWinsCancellationAfterFulfillment() async throws { + let promise = ReplyPromise() + let startGate = WebInspectorTestGate() + #expect(promise.fulfill(.success(47))) + + let waiter = Task { + await startGate.waiter.wait() + return try await promise.value() + } + waiter.cancel() + + #expect(try await waiter.value == 47) + #expect(promise.bookkeepingCountForTesting() == 0) +} + +@Test +func replyPromiseCancellationAndFulfillmentRaceResumesExactlyOnce() async throws { + for value in 0..<100 { + let promise = ReplyPromise() + let raceGate = WebInspectorTestGate() + let waiter = Task { + try await promise.value() + } + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + + let cancellation = Task { + await raceGate.waiter.wait() + waiter.cancel() + } + let fulfillment = Task { + await raceGate.waiter.wait() + return promise.fulfill(.success(value)) + } + raceGate.open() + await cancellation.value + #expect(await fulfillment.value) + + do { + let result = try await waiter.value + #expect(result == value) + } catch is CancellationError { + // Cancellation and fulfillment have independent linearization + // points, so either terminal outcome may reach this waiter. + } catch { + Issue.record("Unexpected ReplyPromise race error: \(error)") + } + #expect(try await promise.value() == value) + #expect(promise.bookkeepingCountForTesting() == 0) + } +} + +@Test +func replyPromiseAndPendingTaskReleaseAfterExplicitTerminal() async throws { + weak var weakPromise: ReplyPromise? + weak var weakTaskToken: ReplyPromiseLifetimeToken? + + do { + let promise = ReplyPromise() + let taskToken = ReplyPromiseLifetimeToken() + weakPromise = promise + weakTaskToken = taskToken + let waiter = Task { + let value = try await promise.value() + withExtendedLifetime(taskToken) {} + return value + } + #expect(await waitForReplyPromiseWaiterCount(1, in: promise)) + + #expect(promise.fulfill(.success(48))) + #expect(try await waiter.value == 48) + } + + for _ in 0..<10_000 { + guard weakPromise != nil || weakTaskToken != nil else { + break + } + await Task.yield() + } + #expect(weakPromise == nil) + #expect(weakTaskToken == nil) +} + +private func waitForReplyPromiseWaiterCount( + _ expectedCount: Int, + in promise: ReplyPromise +) async -> Bool { + for _ in 0..<10_000 { + if promise.waiterCountForTesting() == expectedCount { + return true + } + await Task.yield() + } + return false +} diff --git a/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift new file mode 100644 index 00000000..45dceaa9 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/TransportReceiverLifecycleTests.swift @@ -0,0 +1,556 @@ +import Dispatch +import Foundation +import Testing +import WebInspectorTestSupport +@testable import WebInspectorProxyKit + +@MainActor +@Test +func nativeInitialTargetDiscoveryAwaitsMainQueueCallbacksAndCoreDrain() async throws { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + let completion = ReceiverCompletionProbe() + + DispatchQueue.main.async { + graph.receiver.receive(pageTargetCreatedMessage(id: "initial-page")) + } + let discoveryTask = Task { @MainActor in + try await NativeConnectionCoreFactory.awaitInitialTargetDiscovery( + receiver: graph.receiver, + core: graph.core + ) + await completion.finish() + } + + await parser.waitUntilBlocked() + await graph.receiver.waitForDrainWaiterForTesting() + #expect(await completion.isFinished == false) + #expect(await graph.core.snapshot().targetsByID.isEmpty) + + await parser.release() + try await discoveryTask.value + + #expect(await completion.isFinished) + #expect(await graph.core.snapshot().currentMainPageTargetID == ProtocolTarget.ID("initial-page")) + await graph.core.close() +} + +@Test +func receiverDrainWatermarkDoesNotWaitForNewerLiveMessages() async { + let parser = ControlledMessageParser(blockingInvocation: 2) + let graph = ReceiverCoreGraph(parser: parser.parse) + + graph.receiver.receive(pageTargetCreatedMessage(id: "initial-page")) + let initialTail = graph.receiver.tailOrdinal() + await graph.receiver.waitUntilDrained(through: initialTail) + + graph.receiver.receive(pageTargetCreatedMessage(id: "live-page")) + await parser.waitUntilBlocked() + let liveTail = graph.receiver.tailOrdinal() + #expect(liveTail > initialTail) + + let initialWaitCompletion = ReceiverCompletionProbe() + let initialWait = Task { + await graph.receiver.waitUntilDrained(through: initialTail) + await initialWaitCompletion.finish() + } + await initialWaitCompletion.waitUntilFinished() + + #expect(await graph.core.snapshot().targetsByID[ProtocolTarget.ID("live-page")] == nil) + await parser.release() + await graph.receiver.waitUntilDrained(through: liveTail) + await initialWait.value + #expect(await graph.core.snapshot().targetsByID[ProtocolTarget.ID("live-page")] != nil) + await graph.core.close() +} + +@MainActor +@Test +func nativeInitialTargetDiscoveryFailsWhenCloseInterruptsItsDrain() async { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + + DispatchQueue.main.async { + graph.receiver.receive(pageTargetCreatedMessage(id: "never-ready")) + } + let discoveryTask = Task { @MainActor in + try await NativeConnectionCoreFactory.awaitInitialTargetDiscovery( + receiver: graph.receiver, + core: graph.core + ) + } + + await parser.waitUntilBlocked() + await graph.receiver.waitForDrainWaiterForTesting() + let closeTask = Task { + await graph.core.close() + } + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + await #expect(throws: TransportSession.Error.transportClosed) { + try await discoveryTask.value + } + + await parser.release() + await closeTask.value + #expect(await graph.core.snapshot().targetsByID.isEmpty) +} + +@Test +func receiverCloseWaitsForRootParseAndPreventsPostCloseMutation() async { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + let closeCompletion = ReceiverCompletionProbe() + + graph.receiver.receive(pageTargetCreatedMessage(id: "late-page")) + await parser.waitUntilBlocked() + + let closeTask = Task { + await graph.core.close() + await closeCompletion.finish() + } + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + #expect(await closeCompletion.isFinished == false) + #expect(await graph.core.snapshot().targetsByID.isEmpty) + + await parser.release() + await closeTask.value + + #expect(await closeCompletion.isFinished) + #expect(await graph.core.snapshot().targetsByID.isEmpty) + #expect(await graph.core.terminalCause == .explicitClose) +} + +@Test +func parserFailureAfterExplicitCloseDoesNotReplaceTerminalCause() async { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + + graph.receiver.receive(pageTargetCreatedMessage(id: "never-applied")) + await parser.waitUntilBlocked() + + let closeTask = Task { + await graph.core.close() + } + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + await parser.release(throwing: true) + await closeTask.value + + #expect(await graph.core.terminalCause == .explicitClose) + #expect(await graph.core.snapshot().targetsByID.isEmpty) +} + +@Test +func receiverCloseDuringNestedParsePreventsNestedRegistryMutation() async { + let parser = ControlledMessageParser(blockingInvocation: 3) + let graph = ReceiverCoreGraph(parser: parser.parse) + await graph.core.receiveRootMessage(pageTargetCreatedMessage(id: "page-main")) + let baseline = await graph.core.snapshot() + + graph.receiver.receive( + targetDispatchMessage( + targetID: "page-main", + message: #"{"method":"Runtime.executionContextCreated","params":{"context":{"id":91,"frameId":"main-frame"}}}"# + ) + ) + await parser.waitUntilBlocked() + + let closeTask = Task { + await graph.core.close() + } + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + await parser.release() + await closeTask.value + + #expect(await graph.core.snapshot() == baseline) + #expect(await graph.core.terminalCause == .explicitClose) +} + +@Test +func malformedInboundMessageHandsTerminationOffWithoutReceiverDeadlock() async throws { + let graph = ReceiverCoreGraph() + + graph.receiver.receive("not-json") + + await #expect(throws: WebInspectorProxyError.protocolViolation("Malformed root protocol message.")) { + try await graph.core.waitUntilClosed() + } + #expect(await graph.core.terminalCause == .protocolViolation("Malformed root protocol message.")) + #expect(await graph.backend.isDetached()) +} + +@Test +func fatalCallbackDuringParseSealsReceiverBeforeTerminalCleanup() async throws { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + + graph.receiver.receive(pageTargetCreatedMessage(id: "late-page")) + await parser.waitUntilBlocked() + + let fatalHandoff = try #require(graph.receiver.fail("native fatal during parse")) + await fatalHandoff.value + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + #expect(await graph.core.terminalCause == .fatal("native fatal during parse")) + #expect(await graph.core.snapshot().targetsByID.isEmpty) + + await parser.release() + + await #expect(throws: WebInspectorProxyError.disconnected("native fatal during parse")) { + try await graph.core.waitUntilClosed() + } + #expect(await graph.core.snapshot().targetsByID.isEmpty) +} + +@Test +func nativeFatalSignalBeforeExplicitCloseKeepsFatalTerminalCause() async throws { + let closeCounter = ReceiverInvocationCounter() + let graph = ReceiverCoreGraph(closeActionObserver: { + await closeCounter.record() + }) + + let fatalHandoff = try #require(graph.receiver.fail("fatal wins")) + let closeTask = Task { + await graph.core.close() + } + await fatalHandoff.value + await closeTask.value + + await #expect(throws: WebInspectorProxyError.disconnected("fatal wins")) { + try await graph.core.waitUntilClosed() + } + #expect(await graph.core.terminalCause == .fatal("fatal wins")) + #expect(await graph.backend.isDetached()) + #expect(await closeCounter.count == 1) +} + +@Test +func explicitCloseClaimBeforeNativeFatalKeepsNormalTerminalCause() async throws { + let closeGate = ReceiverAsyncGate() + let backend = FakeTransportBackend() + let core = ConnectionCore( + backend: backend, + closeAction: { + await closeGate.waitUntilReleased() + } + ) + + let closeTask = Task { + await core.close() + } + await closeGate.waitUntilStarted() + + #expect(core.failFromNativeCallback("too late") == nil) + + await closeGate.release() + await closeTask.value + try await core.waitUntilClosed() + #expect(await core.terminalCause == .explicitClose) +} + +@Test +func receiverResumesAllConcurrentCloseWaitersAfterOneDrainStops() async { + let parser = ControlledMessageParser(blockingInvocation: 1) + let graph = ReceiverCoreGraph(parser: parser.parse) + let firstCompletion = ReceiverCompletionProbe() + let secondCompletion = ReceiverCompletionProbe() + + graph.receiver.receive(#"{"method":"Unknown.event","params":{}}"#) + await parser.waitUntilBlocked() + + let firstClose = Task { + await graph.receiver.close() + await firstCompletion.finish() + } + let secondClose = Task { + await graph.receiver.close() + await secondCompletion.finish() + } + await waitForReceiverCloseWaiterCount(2, receiver: graph.receiver) + + #expect(await firstCompletion.isFinished == false) + #expect(await secondCompletion.isFinished == false) + + await parser.release() + await firstClose.value + await secondClose.value + + #expect(await firstCompletion.isFinished) + #expect(await secondCompletion.isFinished) + await graph.core.close() +} + +@Test +func terminalTaskDoesNotKeepCoreAliveAcrossExternalCloseWait() async { + let closeGate = ReceiverAsyncGate() + let operationCompletion = ReceiverCompletionProbe() + let backend = FakeTransportBackend() + var core: ConnectionCore? = ConnectionCore( + backend: backend, + closeAction: { + await closeGate.waitUntilReleased() + await operationCompletion.finish() + } + ) + weak let weakCore = core + + let terminalHandoff = core?.failFromNativeCallback("terminal operation") + await terminalHandoff?.value + await closeGate.waitUntilStarted() + core = nil + + #expect(weakCore == nil) + await closeGate.release() + await operationCompletion.waitUntilFinished() +} + +@Test +func explicitCloseKeepsStructuredScopeOpenUntilReceiverQuiesces() async throws { + let parser = ControlledMessageParser(blockingInvocation: 2) + let graph = ReceiverCoreGraph(parser: parser.parse) + await graph.core.receiveRootMessage(pageTargetCreatedMessage(id: "page-main")) + let proxy = try await WebInspectorProxy(transport: graph.core) + let structuredReady = ReceiverAsyncGate() + let structuredCompletion = ReceiverCompletionProbe() + + let structuredTask = Task { + do { + return try await proxy.page.dom.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await structuredReady.waitUntilReleased() + let terminal = try await iterator.next() + await structuredCompletion.finish() + return terminal == nil + } + } catch { + return false + } + } + await structuredReady.waitUntilStarted() + await structuredReady.release() + + graph.receiver.receive(#"{"method":"Unknown.event","params":{}}"#) + await parser.waitUntilBlocked() + + let closeTask = Task { + await proxy.close() + } + await waitForReceiverCloseWaiterCount(1, receiver: graph.receiver) + + #expect(await structuredCompletion.isFinished == false) + #expect(await graph.core.activeEventScopeSubscriberCountForTesting() == 1) + + await parser.release() + await closeTask.value + + #expect(await structuredTask.value) + #expect(await structuredCompletion.isFinished) +} + +private struct ReceiverCoreGraph: Sendable { + let receiver: TransportReceiver + let backend: FakeTransportBackend + let core: ConnectionCore + + init( + parser: @escaping ConnectionCore.MessageParser = { + try await TransportMessageParser.parse($0) + }, + closeActionObserver: @escaping @Sendable () async -> Void = {} + ) { + let receiver = TransportReceiver() + let backend = FakeTransportBackend() + let core = ConnectionCore( + backend: backend, + responseTimeout: nil, + messageParser: parser, + closeAction: { + await receiver.close() + await closeActionObserver() + await backend.detach() + } + ) + receiver.setCore(core) + self.receiver = receiver + self.backend = backend + self.core = core + } +} + +private actor ControlledMessageParser { + private struct InjectedFailure: Error {} + + private let blockingInvocation: Int + private var invocationCount = 0 + private var isBlocked = false + private var isReleased = false + private var shouldThrow = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + init(blockingInvocation: Int) { + precondition(blockingInvocation > 0) + self.blockingInvocation = blockingInvocation + } + + func parse(_ message: String) async throws -> ParsedProtocolMessage { + invocationCount += 1 + guard invocationCount == blockingInvocation else { + return try await TransportMessageParser.parse(message) + } + + isBlocked = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + + if !isReleased { + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + if shouldThrow { + throw InjectedFailure() + } + return try await TransportMessageParser.parse(message) + } + + func waitUntilBlocked() async { + guard !isBlocked else { + return + } + await withCheckedContinuation { continuation in + if isBlocked { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release(throwing: Bool = false) { + shouldThrow = throwing + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +private actor ReceiverAsyncGate { + private var isStarted = false + private var isReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitUntilReleased() async { + isStarted = true + let startWaiters = self.startWaiters + self.startWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + guard !isReleased else { + return + } + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseWaiters.append(continuation) + } + } + } + + func waitUntilStarted() async { + guard !isStarted else { + return + } + await withCheckedContinuation { continuation in + if isStarted { + continuation.resume() + } else { + startWaiters.append(continuation) + } + } + } + + func release() { + isReleased = true + let releaseWaiters = self.releaseWaiters + self.releaseWaiters.removeAll() + for waiter in releaseWaiters { + waiter.resume() + } + } +} + +private actor ReceiverCompletionProbe { + private(set) var isFinished = false + private var finishWaiters: [CheckedContinuation] = [] + + func finish() { + guard !isFinished else { + return + } + isFinished = true + let finishWaiters = self.finishWaiters + self.finishWaiters.removeAll() + for waiter in finishWaiters { + waiter.resume() + } + } + + func waitUntilFinished() async { + guard !isFinished else { + return + } + await withCheckedContinuation { continuation in + if isFinished { + continuation.resume() + } else { + finishWaiters.append(continuation) + } + } + } +} + +private actor ReceiverInvocationCounter { + private(set) var count = 0 + + func record() { + count += 1 + } +} + +private func waitForReceiverCloseWaiterCount( + _ count: Int, + receiver: TransportReceiver +) async { + while receiver.closeWaiterCountForTesting() < count { + await Task.yield() + } +} + +private func pageTargetCreatedMessage(id: String) -> String { + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"\#(id)","type":"page","frameId":"main-frame","isProvisional":false}}}"# +} + +private func targetDispatchMessage(targetID: String, message: String) -> String { + let escapedMessage = message + .replacingOccurrences(of: #"\"#, with: #"\\"#) + .replacingOccurrences(of: #"""#, with: #"\""#) + return #"{"method":"Target.dispatchMessageFromTarget","params":{"targetId":"\#(targetID)","message":"\#(escapedMessage)"}}"# +} diff --git a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift index 1d7a352e..2e8197ed 100644 --- a/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportReplyStoreTests.swift @@ -3,69 +3,129 @@ import Testing @testable import WebInspectorProxyKit @Test -func replyStoreRetargetsWrapperIndexWithPendingTargetReply() { +func replyStoreRemovingTargetCleansWrapperIndex() { var store = TransportReplyStore() let oldKey = TransportSession.ReplyKey(targetID: .init("frame-old"), commandID: 7) - let newKey = TransportSession.ReplyKey(targetID: .init("frame-new"), commandID: 7) + let pending = capabilityPendingReply(targetID: .init("frame-old"), operationID: 11) store.insertTargetReply( - pendingReply(targetID: .init("frame-old")), + pending, key: oldKey, rootWrapperID: 100 ) - store.retargetPendingReplies(from: .init("frame-old"), to: .init("frame-new")) + let removed = store.removeTargetReplies(for: .init("frame-old")) - #expect(store.pendingTargetReplyKeys == [newKey]) - #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == newKey) - #expect(store.removeTargetReply(for: newKey) != nil) + #expect(removed.count == 1) + #expect(removed.first?.purpose == pending.purpose) + #expect(store.pendingTargetReplyKeys.isEmpty) #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) } @Test -func replyStoreUsesCommandIndexForRetargetedTimeout() { +func replyStoreTimeoutRemovesOriginalTargetReplyAndIndexes() { var store = TransportReplyStore() let oldKey = TransportSession.ReplyKey(targetID: .init("frame-old"), commandID: 7) + let pending = capabilityPendingReply(targetID: .init("frame-old"), operationID: 12) store.insertTargetReply( - pendingReply(targetID: .init("frame-old")), + pending, key: oldKey, rootWrapperID: 100 ) - store.retargetPendingReplies(from: .init("frame-old"), to: .init("frame-new")) let removed = store.removeTargetReplyForTimeout(oldKey) + #expect(removed?.targetID == ProtocolTarget.ID("frame-old")) + #expect(removed?.purpose == pending.purpose) + #expect(store.pendingTargetReplyKeys.isEmpty) + #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) +} + +@Test +func replyStoreRetargetedTimeoutPreservesPurpose() { + var store = TransportReplyStore() + let originalKey = TransportSession.ReplyKey(targetID: .init("frame-old"), commandID: 7) + let currentKey = TransportSession.ReplyKey(targetID: .init("frame-new"), commandID: 7) + let pending = capabilityPendingReply(targetID: .init("frame-new"), operationID: 13) + + store.insertTargetReply( + pending, + key: currentKey, + rootWrapperID: 100 + ) + let removed = store.removeTargetReplyForTimeout(originalKey) + #expect(removed?.targetID == ProtocolTarget.ID("frame-new")) + #expect(removed?.purpose == pending.purpose) #expect(store.pendingTargetReplyKeys.isEmpty) + #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) } @Test -func replyStoreReplacingCommandCleansStaleIndexes() { +func replyStoreRemovingThenReusingCommandCleansStaleIndexes() { var store = TransportReplyStore() let oldKey = TransportSession.ReplyKey(targetID: .init("frame-old"), commandID: 7) let newKey = TransportSession.ReplyKey(targetID: .init("frame-new"), commandID: 7) store.insertTargetReply( - pendingReply(targetID: .init("frame-old")), + directPendingReply(targetID: .init("frame-old")), key: oldKey, rootWrapperID: 100 ) + let removed = store.removeTargetReply(for: oldKey) store.insertTargetReply( - pendingReply(targetID: .init("frame-new")), + directPendingReply(targetID: .init("frame-new")), key: newKey, rootWrapperID: 200 ) + #expect(removed?.purpose == .direct(bindingGeneration: nil, documentEpoch: nil)) #expect(store.pendingTargetReplyKeys == [newKey]) #expect(store.takeTargetReplyKey(forRootWrapperID: 100) == nil) #expect(store.takeTargetReplyKey(forRootWrapperID: 200) == newKey) } -private func pendingReply(targetID: ProtocolTarget.ID) -> TransportSession.PendingReply { - TransportSession.PendingReply( +@Test +func replyStoreRootRemovalPreservesDirectPurpose() { + var store = TransportReplyStore() + store.insertRootReply( + directPendingReply(targetID: nil), + commandID: 42 + ) + + let removed = store.removePendingReply(.root(42)) + + #expect(removed?.purpose == .direct(bindingGeneration: nil, documentEpoch: nil)) + #expect(store.pendingRootReplyIDs.isEmpty) +} + +private func directPendingReply( + targetID: ProtocolTarget.ID? +) -> TransportSession.PendingReply { + TransportSession.PendingReply.direct( domain: .dom, method: "DOM.getDocument", targetID: targetID, promise: ReplyPromise(), - hasBufferedProvisionalResponse: false + bindingGeneration: nil, + documentEpoch: nil + ) +} + +private func capabilityPendingReply( + targetID: ProtocolTarget.ID, + operationID: UInt64 +) -> TransportSession.PendingReply { + TransportSession.PendingReply.capability( + domain: .network, + method: "Network.enable", + targetID: targetID, + promise: ReplyPromise(), + key: ConnectionCapabilityKey( + route: RoutingTargetID(targetID.rawValue), + targetID: WebInspectorTarget.ID(targetID.rawValue), + domain: .network + ), + generation: WebInspectorPage.Generation(rawValue: 3), + operationID: operationID ) } diff --git a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift index aa65f6e2..e89976ef 100644 --- a/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift +++ b/Tests/WebInspectorProxyKitTests/TransportSessionTests.swift @@ -164,7 +164,7 @@ func targetReplyCarriesPerDomainSequenceWatermarks() async throws { await receiveTargetDispatch( session, targetID: .init("page-main"), - message: #"{"method":"DOM.setChildNodes","params":{"parentId":2,"nodes":[{"nodeId":3,"nodeType":1,"nodeName":"DIV"}]}}"# + message: #"{"method":"DOM.setChildNodes","params":{"parentId":2,"nodes":[{"nodeId":3,"nodeType":1,"nodeName":"DIV","localName":"div","nodeValue":""}]}}"# ) await receiveTargetDispatch( session, @@ -373,46 +373,6 @@ func fakeBackendTargetMessageWaiterCancellationDoesNotResumeWithLaterMessage() a #expect(try messageID(message.message) == 1) } -@Test -func detachFailsPendingRepliesAndClosesStreams() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - let stream = await session.events(for: .dom) - let streamTask = Task { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - let sendTask = Task { - try await session.send( - ProtocolCommand(domain: .target, method: "Target.setPauseOnStart", routing: .root) - ) - } - _ = try await waitForRootMessage(backend) - - await session.detach() - - await #expect(throws: TransportSession.Error.transportClosed) { - try await sendTask.value - } - let event = await streamTask.value - #expect(event == nil) - #expect(await backend.isDetached()) -} - -@Test -func eventStreamsRequestedAfterDetachFinishImmediately() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - - await session.detach() - - let domainStream = await session.events(for: .dom) - let orderedStream = await session.orderedEvents() - - #expect(try await nextEvent(from: domainStream) == nil) - #expect(try await nextEvent(from: orderedStream) == nil) -} - @Test func waitForCurrentMainPageTargetFailsAfterDetach() async throws { let backend = FakeTransportBackend() @@ -527,13 +487,13 @@ func provisionalPageTargetWithKnownNonMainFrameIsClassifiedAsFrame() async throw } @Test -func oldlessProvisionalPageCommitWithNonMainFrameDoesNotRetargetCurrentPage() async throws { +func provisionalPageCommitWithNonMainFrameDoesNotRetargetCurrentPage() async throws { let backend = FakeTransportBackend() let session = TransportSession(backend: backend) await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"iframe-page-provisional","type":"page","frameId":"child-frame","isProvisional":true}}}"#) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"newTargetId":"iframe-page-provisional"}}"#) + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"missing-old-frame","newTargetId":"iframe-page-provisional"}}"#) let snapshot = await session.snapshot() #expect(snapshot.currentMainPageTargetID == ProtocolTarget.ID("page-main")) @@ -609,40 +569,136 @@ func subframeCommitDoesNotConsumeCurrentMainPageTarget() async throws { } @Test -func targetCommitRetargetsPendingRepliesToCommittedTarget() async throws { +func targetCommitFailsPendingRepliesForOldBindingAsStale() async throws { let backend = FakeTransportBackend() let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","isProvisional":true}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) let sendTask = Task { try await session.send( - ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("frame-provisional"))) + ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("page-main"))) ) } - let sent = try await waitForTargetMessage(backend) - let innerID = try messageID(sent.message) + _ = try await waitForTargetMessage(backend) + + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) + + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await sendTask.value + } + #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) +} + +@Test +func targetCommitFailsDirectCommandsForOldMainAndFrameBinding() async throws { + let backend = FakeTransportBackend() + let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-child","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) + + let mainTask = Task { + try await session.send(ProtocolCommand( + domain: .page, + method: "Page.reload", + routing: .target(.init("page-main")) + )) + } + let frameTask = Task { + try await session.send(ProtocolCommand( + domain: .page, + method: "Page.reload", + routing: .target(.init("frame-child")) + )) + } + _ = try await backend.waitForTargetMessage(method: "Page.reload", ordinal: 0) + _ = try await backend.waitForTargetMessage(method: "Page.reload", ordinal: 1) + + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) + + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await mainTask.value + } + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await frameTask.value + } + #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) + await session.close() +} + +@Test +func documentUpdatedFailsDirectDOMAndCSSButPreservesNetworkCommand() async throws { + let backend = FakeTransportBackend() + let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + + let domTask = Task { + try await session.send(ProtocolCommand( + domain: .dom, + method: "DOM.querySelector", + routing: .target(.init("page-main")) + )) + } + let cssTask = Task { + try await session.send(ProtocolCommand( + domain: .css, + method: "CSS.getMatchedStylesForNode", + routing: .target(.init("page-main")) + )) + } + let networkTask = Task { + try await session.send(ProtocolCommand( + domain: .network, + method: "Network.getResponseBody", + routing: .target(.init("page-main")) + )) + } + let dom = try await backend.waitForTargetMessage(method: "DOM.querySelector") + let css = try await backend.waitForTargetMessage(method: "CSS.getMatchedStylesForNode") + let network = try await backend.waitForTargetMessage(method: "Network.getResponseBody") - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) await receiveTargetDispatch( session, - targetID: .init("frame-committed"), - message: ##"{"id":\##(innerID),"result":{"root":{"nodeId":1,"nodeType":9,"nodeName":"#document"}}}"## + targetID: .init("page-main"), + message: #"{"method":"DOM.documentUpdated","params":{}}"# ) - let result = try await sendTask.value - - #expect(result.targetID == ProtocolTarget.ID("frame-committed")) + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await domTask.value + } + await #expect(throws: WebInspectorProxyError.staleIdentifier) { + try await cssTask.value + } + await receiveTargetDispatch( + session, + targetID: dom.targetIdentifier, + message: #"{"id":\#(try messageID(dom.message)),"result":{}}"# + ) + await receiveTargetDispatch( + session, + targetID: css.targetIdentifier, + message: #"{"id":\#(try messageID(css.message)),"result":{}}"# + ) + await receiveTargetDispatch( + session, + targetID: network.targetIdentifier, + message: #"{"id":\#(try messageID(network.message)),"result":{}}"# + ) + _ = try await networkTask.value #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) + await session.close() } @Test func provisionalTargetReplyIsBufferedUntilCommit() async throws { let backend = FakeTransportBackend() let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","isProvisional":true}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) let sendTask = Task { try await session.send( - ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("frame-provisional"))) + ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("page-next"))) ) } let sent = try await waitForTargetMessage(backend) @@ -650,17 +706,17 @@ func provisionalTargetReplyIsBufferedUntilCommit() async throws { await receiveTargetDispatch( session, - targetID: .init("frame-provisional"), + targetID: .init("page-next"), message: ##"{"id":\##(innerID),"result":{"root":{"nodeId":1,"nodeType":9,"nodeName":"#document"}}}"## ) #expect(await session.snapshot().pendingTargetReplyKeys == [ - TransportSession.ReplyKey(targetID: .init("frame-provisional"), commandID: innerID), + TransportSession.ReplyKey(targetID: .init("page-next"), commandID: innerID), ]) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) let result = try await sendTask.value - #expect(result.targetID == ProtocolTarget.ID("frame-committed")) + #expect(result.targetID == ProtocolTarget.ID("page-next")) #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) } @@ -678,11 +734,12 @@ func bufferedProvisionalTargetReplySurvivesResponseTimeoutBeforeCommit() async t await responseTimeout.recordHandledTimeout() } ) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","isProvisional":true}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) let sendTask = Task { try await session.send( - ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("frame-provisional"))) + ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("page-next"))) ) } let sent = try await waitForTargetMessage(backend) @@ -690,7 +747,7 @@ func bufferedProvisionalTargetReplySurvivesResponseTimeoutBeforeCommit() async t await receiveTargetDispatch( session, - targetID: .init("frame-provisional"), + targetID: .init("page-next"), message: ##"{"id":\##(innerID),"result":{"root":{"nodeId":1,"nodeType":9,"nodeName":"#document"}}}"## ) await responseTimeout.waitUntilSuspended() @@ -698,106 +755,18 @@ func bufferedProvisionalTargetReplySurvivesResponseTimeoutBeforeCommit() async t await responseTimeout.waitUntilHandledTimeout() #expect(await session.snapshot().pendingTargetReplyKeys == [ - TransportSession.ReplyKey(targetID: .init("frame-provisional"), commandID: innerID), + TransportSession.ReplyKey(targetID: .init("page-next"), commandID: innerID), ]) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) let result = try await sendTask.value - #expect(result.targetID == ProtocolTarget.ID("frame-committed")) + #expect(result.targetID == ProtocolTarget.ID("page-next")) #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) } @Test -func oldlessTargetCommitInfersSoleProvisionalTarget() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","parentFrameId":"main-frame","isProvisional":true}}}"#) - - let sendTask = Task { - try await session.send( - ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("frame-provisional"))) - ) - } - let sent = try await waitForTargetMessage(backend) - let innerID = try messageID(sent.message) - - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"newTargetId":"frame-committed"}}"#) - await receiveTargetDispatch( - session, - targetID: .init("frame-committed"), - message: ##"{"id":\##(innerID),"result":{"root":{"nodeId":1,"nodeType":9,"nodeName":"#document"}}}"## - ) - let result = try await sendTask.value - let snapshot = await session.snapshot() - - #expect(result.targetID == ProtocolTarget.ID("frame-committed")) - #expect(snapshot.targetsByID[ProtocolTarget.ID("frame-provisional")] == nil) - #expect(snapshot.targetsByID[ProtocolTarget.ID("frame-committed")]?.frameID == ProtocolFrame.ID("ad-frame")) - #expect(snapshot.frameTargetIDsByFrameID[ProtocolFrame.ID("ad-frame")] == ProtocolTarget.ID("frame-committed")) -} - -@Test -func provisionalTargetMessagesAreDispatchedAfterCommitTargetEvent() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) - - let targetStream = await session.events(for: .target) - let domStream = await session.events(for: .dom) - let targetEvents = ProtocolEventRecorder(stream: targetStream) - let domEvents = ProtocolEventRecorder(stream: domStream) - - await receiveTargetDispatch( - session, - targetID: .init("page-next"), - message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":3,"childNodeCount":0}}"# - ) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) - - let targetEvent = try await targetEvents.event() - let domEvent = try await domEvents.event() - - #expect(targetEvent.method == "Target.didCommitProvisionalTarget") - #expect(domEvent.method == "DOM.childNodeCountUpdated") - #expect(domEvent.targetID == ProtocolTarget.ID("page-next")) - #expect(domEvent.sequence > targetEvent.sequence) - #expect(domEvent.receivedSequence(for: .target) == targetEvent.sequence) -} - -@Test -func oldProvisionalTargetMessagesAreDispatchedAfterCommitTargetEvent() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend, responseTimeout: testResponseTimeout) - - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","parentFrameId":"main-frame","isProvisional":true}}}"#) - - let targetStream = await session.events(for: .target) - let domStream = await session.events(for: .dom) - let targetEvents = ProtocolEventRecorder(stream: targetStream) - let domEvents = ProtocolEventRecorder(stream: domStream) - - await receiveTargetDispatch( - session, - targetID: .init("frame-provisional"), - message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":3,"childNodeCount":1}}"# - ) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) - - let targetEvent = try await targetEvents.event() - let domEvent = try await domEvents.event() - - #expect(targetEvent.method == "Target.didCommitProvisionalTarget") - #expect(domEvent.method == "DOM.childNodeCountUpdated") - #expect(domEvent.targetID == ProtocolTarget.ID("frame-committed")) - #expect(domEvent.sequence > targetEvent.sequence) - #expect(domEvent.receivedSequence(for: .target) == targetEvent.sequence) -} - -@Test -func retargetedPendingReplyStillTimesOutAfterCommit() async throws { +func oldBindingPendingReplyFailsAsStaleAtCommitBeforeTimeout() async throws { let backend = FakeTransportBackend() let responseTimeout = ManualResponseTimeout() let session = TransportSession( @@ -807,20 +776,20 @@ func retargetedPendingReplyStillTimesOutAfterCommit() async throws { try await responseTimeout.sleep(for: duration) } ) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","isProvisional":true}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) + await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"#) let sendTask = Task { try await session.send( - ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("frame-provisional"))) + ProtocolCommand(domain: .dom, method: "DOM.getDocument", routing: .target(.init("page-main"))) ) } _ = try await waitForTargetMessage(backend) await responseTimeout.waitUntilSuspended() - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) - await responseTimeout.fireNext() + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"#) - await #expect(throws: TransportSession.Error.replyTimeout(method: "DOM.getDocument", targetID: .init("frame-provisional"))) { + await #expect(throws: WebInspectorProxyError.staleIdentifier) { try await sendTask.value } #expect(await session.snapshot().pendingTargetReplyKeys.isEmpty) @@ -832,8 +801,8 @@ func ambiguousTargetCommitPreservesExistingMetadataAndDoesNotInventTarget() asyn let session = TransportSession(backend: backend) await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-existing","type":"frame","frameId":"ad-frame","parentFrameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"newTargetId":"frame-existing"}}"#) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"newTargetId":"missing-target"}}"#) + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"missing-old","newTargetId":"frame-existing"}}"#) + await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"another-missing-old","newTargetId":"missing-target"}}"#) let snapshot = await session.snapshot() #expect(snapshot.targetsByID[ProtocolTarget.ID("frame-existing")]?.kind == .frame) @@ -841,60 +810,6 @@ func ambiguousTargetCommitPreservesExistingMetadataAndDoesNotInventTarget() asyn #expect(snapshot.targetsByID[ProtocolTarget.ID("missing-target")] == nil) } -@Test -func rootScopedRuntimeDOMAndConsoleEventsResolveToCurrentPageTarget() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let runtimeStream = await session.events(for: .runtime) - let domStream = await session.events(for: .dom) - let consoleStream = await session.events(for: .console) - let runtimeEvents = ProtocolEventRecorder(stream: runtimeStream) - let domEvents = ProtocolEventRecorder(stream: domStream) - let consoleEvents = ProtocolEventRecorder(stream: consoleStream) - - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"Runtime.executionContextCreated","params":{"context":{"id":11,"frameId":"main-frame"}}}"#) - await session.receiveRootMessage(#"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":1,"childNodeCount":2}}"#) - await session.receiveRootMessage(#"{"method":"Console.messageAdded","params":{"message":{"text":"hello"}}}"#) - let snapshot = await session.snapshot() - - #expect(snapshot.executionContextsByKey[contextKey("page-main", 11)]?.targetID == ProtocolTarget.ID("page-main")) - let runtimeEvent = try await runtimeEvents.event() - let domEvent = try await domEvents.event() - let consoleEvent = try await consoleEvents.event() - #expect(runtimeEvent.targetID == ProtocolTarget.ID("page-main")) - #expect(domEvent.targetID == ProtocolTarget.ID("page-main")) - #expect(consoleEvent.targetID == ProtocolTarget.ID("page-main")) -} - -@Test -func rootScopedDocumentUpdatedRemainsTargetless() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let domStream = await session.events(for: .dom) - let domEvents = ProtocolEventRecorder(stream: domStream) - - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"DOM.documentUpdated","params":{}}"#) - - let event = try await domEvents.event() - #expect(event.targetID == nil) -} - -@Test -func rootScopedInspectorEventsRemainTargetless() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let inspectorStream = await session.events(for: .inspector) - let inspectorEvents = ProtocolEventRecorder(stream: inspectorStream) - - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"Inspector.inspect","params":{"object":{"type":"object","subtype":"node","objectId":"node-1"}}}"#) - - let event = try await inspectorEvents.event() - #expect(event.targetID == nil) -} - @Test func runtimeExecutionContextMapsToDeliveringTarget() async throws { let backend = FakeTransportBackend() @@ -1044,119 +959,66 @@ func runtimeExecutionContextRegistryAppliesTeardownEvents() async throws { } @Test -func domainStreamsReceiveIndependentTargetEventsInOrder() async throws { +func nativeFatalCallbackOwnsTerminalCauseAndFailsPendingWork() async throws { let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let domStream = await session.events(for: .dom) - let cssStream = await session.events(for: .css) - let consoleStream = await session.events(for: .console) - let networkStream = await session.events(for: .network) - - let domEvents = ProtocolEventRecorder(stream: domStream) - let cssEvents = ProtocolEventRecorder(stream: cssStream) - let consoleEvents = ProtocolEventRecorder(stream: consoleStream) - let networkEvents = ProtocolEventRecorder(stream: networkStream) - - await receiveTargetDispatch(session, targetID: .init("frame-A"), message: #"{"method":"DOM.setChildNodes","params":{"parentId":1,"nodes":[]}}"#) - await receiveTargetDispatch(session, targetID: .init("frame-A"), message: #"{"method":"CSS.styleSheetChanged","params":{"styleSheetId":"s1"}}"#) - await receiveTargetDispatch(session, targetID: .init("frame-A"), message: #"{"method":"Console.messageAdded","params":{"message":{"text":"hello"}}}"#) - await receiveTargetDispatch(session, targetID: .init("page-main"), message: #"{"method":"Network.requestWillBeSent","params":{"requestId":"r1","request":{"url":"https://example.com"},"timestamp":1}}"#) - - let domEvent = try await domEvents.event() - let cssEvent = try await cssEvents.event() - let consoleEvent = try await consoleEvents.event() - let networkEvent = try await networkEvents.event() - #expect(domEvent.method == "DOM.setChildNodes") - #expect(cssEvent.method == "CSS.styleSheetChanged") - #expect(consoleEvent.method == "Console.messageAdded") - #expect(networkEvent.method == "Network.requestWillBeSent") -} + let core = ConnectionCore(backend: backend, responseTimeout: nil) + let receiver = TransportReceiver() + receiver.setCore(core) -@Test -func rootCSSStyleSheetEventsResolveFrameTargetFromFrameIDAndStyleSheetOwnership() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let cssStream = await session.events(for: .css) - let cssEvents = ProtocolEventRecorder(stream: cssStream) + let sendTask = Task { + try await core.send( + ProtocolCommand(domain: .target, method: "Target.setPauseOnStart", routing: .root) + ) + } + _ = try await waitForRootMessage(backend) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-A","type":"frame","frameId":"frame-A","parentFrameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-frame","frameId":"frame-A"}}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetChanged","params":{"styleSheetId":"sheet-frame"}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetRemoved","params":{"styleSheetId":"sheet-frame"}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetChanged","params":{"styleSheetId":"sheet-frame"}}"#) - - let events = try await cssEvents.events(prefix: 4) - #expect(events.map(\.method) == ["CSS.styleSheetAdded", "CSS.styleSheetChanged", "CSS.styleSheetRemoved", "CSS.styleSheetChanged"]) - #expect(events.map(\.targetID) == [ - ProtocolTarget.ID("frame-A"), - ProtocolTarget.ID("frame-A"), - ProtocolTarget.ID("frame-A"), - nil, - ]) -} + let closeWaitTask = Task { + try await core.waitUntilClosed() + } + await core.waitForCloseWaiterForTesting() -@Test -func rootCSSStyleSheetAddedBeforeFrameTargetDoesNotPinSheetToPage() async throws { - let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let cssStream = await session.events(for: .css) - let cssEvents = ProtocolEventRecorder(stream: cssStream) + receiver.fail("native callback failed") - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-late-frame","frameId":"late-frame"}}}"#) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-late","type":"frame","frameId":"late-frame","parentFrameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetChanged","params":{"styleSheetId":"sheet-late-frame"}}"#) - - let events = try await cssEvents.events(prefix: 3) - #expect(events.map(\.method) == ["CSS.styleSheetAdded", "CSS.styleSheetAdded", "CSS.styleSheetChanged"]) - #expect(events.map(\.targetID) == [ - nil, - ProtocolTarget.ID("frame-late"), - ProtocolTarget.ID("frame-late"), - ]) + await #expect(throws: TransportSession.Error.transportFailure("native callback failed")) { + _ = try await sendTask.value + } + await #expect(throws: WebInspectorProxyError.disconnected("native callback failed")) { + try await closeWaitTask.value + } + #expect(await core.terminalCause == .fatal("native callback failed")) + #expect(await backend.isDetached()) + #expect(await core.snapshot().pendingRootReplyIDs.isEmpty) } @Test -func rootCSSStyleSheetAddedBeforeProvisionalFrameTargetReplaysAfterCommit() async throws { +func receiverDoesNotKeepExplicitlyClosedConnectionCoreAlive() async { let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let cssStream = await session.events(for: .css) - let cssEvents = ProtocolEventRecorder(stream: cssStream) + let receiver = TransportReceiver() + weak var weakCore: ConnectionCore? - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-main","type":"page","frameId":"main-frame","isProvisional":false}}}"#) - await session.receiveRootMessage(#"{"method":"CSS.styleSheetAdded","params":{"header":{"styleSheetId":"sheet-provisional-frame","frameId":"ad-frame"}}}"#) - await session.receiveRootMessage(#"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-provisional","type":"frame","frameId":"ad-frame","parentFrameId":"main-frame","isProvisional":true}}}"#) - await session.receiveRootMessage(#"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"frame-provisional","newTargetId":"frame-committed"}}"#) + do { + let core = ConnectionCore(backend: backend) + weakCore = core + receiver.setCore(core) + await core.close() + } - let events = try await cssEvents.events(prefix: 2) - #expect(events.map(\.method) == ["CSS.styleSheetAdded", "CSS.styleSheetAdded"]) - #expect(events.map(\.targetID) == [ - nil, - ProtocolTarget.ID("frame-committed"), - ]) + #expect(weakCore == nil) } @Test -func orderedStreamReceivesTargetEventsAcrossDomainsInTransportOrder() async throws { +func receiverDoesNotKeepDroppedConnectionCoreAlive() { let backend = FakeTransportBackend() - let session = TransportSession(backend: backend) - let stream = await session.orderedEvents() - let events = ProtocolEventRecorder(stream: stream) - - await receiveTargetDispatch(session, targetID: .init("page-main"), message: #"{"method":"DOM.documentUpdated","params":{}}"#) - await receiveTargetDispatch(session, targetID: .init("page-main"), message: #"{"method":"Network.requestWillBeSent","params":{"requestId":"r1","request":{"url":"https://example.com"},"timestamp":1}}"#) - await receiveTargetDispatch(session, targetID: .init("page-main"), message: #"{"method":"Runtime.executionContextCreated","params":{"context":{"id":7}}}"#) - await receiveTargetDispatch(session, targetID: .init("page-main"), message: #"{"method":"DOM.childNodeCountUpdated","params":{"nodeId":3,"childNodeCount":2}}"#) - - let recordedEvents = try await events.events(prefix: 4) - #expect(recordedEvents.map(\.method) == [ - "DOM.documentUpdated", - "Network.requestWillBeSent", - "Runtime.executionContextCreated", - "DOM.childNodeCountUpdated", - ]) - #expect(recordedEvents.map(\.sequence) == [1, 2, 3, 4]) + let receiver = TransportReceiver() + weak var weakCore: ConnectionCore? + + do { + let core = ConnectionCore(backend: backend) + weakCore = core + receiver.setCore(core) + } + + #expect(weakCore == nil) } private final class ProtocolEventRecorder: Sendable { @@ -1186,6 +1048,18 @@ private final class ProtocolEventRecorder: Sendable { } } +private actor CountingTransportBackend: TransportBackend { + private(set) var detachCount = 0 + + func sendJSONString(_ message: String) async throws { + _ = message + } + + func detach() async { + detachCount += 1 + } +} + private actor ProtocolEventRecorderStorage { private struct CountWaiter: Sendable { var count: Int diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift index fdcc833e..8149ad5b 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyKitBackendTests.swift @@ -3,581 +3,215 @@ import WebInspectorProxyKit import WebInspectorProxyKitTesting @Test -func domGetDocumentDispatchesToTargetRoute() async throws { +func rawPeerDispatchesDOMDocumentCommandThroughProductionTargetRoute() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() let target = try await runtime.proxy.waitForCurrentPage() - let expectedNode = DOM.Node( - id: DOM.Node.ID("document"), - nodeType: 9, - nodeName: "#document" - ) - - await runtime.backend.enqueue(expectedNode, for: "DOM", method: "getDocument") - - let node = try await target.dom.getDocument() - #expect(node.id == expectedNode.id) - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == target.id) - #expect(command.route == target.route) - #expect(command.domain == "DOM") - #expect(command.method == "getDocument") - #expect(command.payload.cast(as: DOM.GetDocumentPayload.self) != nil) -} - -@Test -func frameTargetFactoryDispatchesDOMCommandsToFrameRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let frameTarget = runtime.proxy.frameTarget(id: WebInspectorTarget.ID("frame-target")) - let expectedNode = DOM.Node( - id: DOM.Node.ID("frame-document"), - nodeType: 9, - nodeName: "#document" - ) - - await runtime.backend.enqueue(expectedNode, for: "DOM", method: "getDocument") - let node = try await frameTarget.dom.getDocument() - #expect(node.id == expectedNode.id) - #expect(frameTarget.kind == .frame) - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == frameTarget.id) - #expect(command.route == RoutingTargetID(frameTarget.id.rawValue)) - #expect(command.domain == "DOM") - #expect(command.method == "getDocument") - #expect(command.payload.cast(as: DOM.GetDocumentPayload.self) != nil) -} - -@Test -func domRequestNodeDispatchesToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let objectID = Runtime.RemoteObject.ID("remote-node") - let expectedNodeID = DOM.Node.ID("selected-node") - - await runtime.backend.enqueue(expectedNodeID, for: "DOM", method: "requestNode") - - let nodeID = try await target.dom.requestNode(forRemoteObject: objectID) - #expect(nodeID == expectedNodeID) - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == target.id) - #expect(command.route == target.route) - #expect(command.domain == "DOM") - #expect(command.method == "requestNode") - let payload = try #require(command.payload.cast(as: DOM.RequestNodePayload.self)) - #expect(payload.objectID == objectID) -} - -@Test -func inspectorInspectResolvesNodeRemoteObjectToDOMInspectEvent() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let objectID = Runtime.RemoteObject.ID("remote-node") - let expectedNodeID = DOM.Node.ID("selected-node") - - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() + let operation = Task { + try await target.dom.getDocument() } - - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) - await runtime.backend.enqueue(expectedNodeID, for: "DOM", method: "requestNode") - - await runtime.backend.emit( - .inspect( - Runtime.RemoteObject( - id: objectID, - kind: .object, - subtype: Runtime.Subtype(rawValue: "node") - ), - hints: .object([:]), - origin: nil - ), - target: target + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "DOM.getDocument") + #expect(command.parameters == .empty) + try await runtime.peer.reply( + to: command, + with: try jsonObject( + ##"{"root":{"nodeId":"document","nodeType":9,"nodeName":"#document","localName":"","nodeValue":"","childNodeCount":0}}"## + ) ) - let event = try #require(try await value(of: eventTask)) - guard case let .inspect(nodeID) = event else { - Issue.record("Expected Inspector.inspect to resolve to DOM.inspect.") - return - } - #expect(nodeID == expectedNodeID) - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == target.id) - #expect(command.route == target.route) - #expect(command.domain == "DOM") - #expect(command.method == "requestNode") - let payload = try #require(command.payload.cast(as: DOM.RequestNodePayload.self)) - #expect(payload.objectID == objectID) + let node = try await operation.value + #expect(node.id == DOM.Node.ID("document")) + await runtime.close() } @Test -func domInspectEventPassesThroughWithoutRequestNode() async throws { +func rawPeerRoutesFrameRequestNodeThroughCurrentPageDOMAgent() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let expectedNodeID = DOM.Node.ID("protocol-node") + try await runtime.peer.createTarget(.init( + id: "frame-target", + type: "frame", + frameID: "child-frame", + parentFrameID: "main-frame" + )) + let frame = runtime.proxy.frameTarget(id: WebInspectorTarget.ID("frame-target")) - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() + let operation = Task { + try await frame.dom.requestNode( + forRemoteObject: Runtime.RemoteObject.ID("remote-node") + ) } + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "DOM.requestNode") + let parameters = try command.parameters.decode(RequestNodeParameters.self) + #expect(parameters.objectId == "remote-node") + try await runtime.peer.reply( + to: command, + with: try jsonObject(#"{"nodeId":"selected-node"}"#) + ) - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) - - await runtime.backend.emit(.inspect(expectedNodeID), target: target) - - let event = try #require(try await value(of: eventTask)) - guard case let .inspect(nodeID) = event else { - Issue.record("Expected DOM.inspect to pass through.") - return - } - #expect(nodeID == expectedNodeID) - #expect(await runtime.backend.recordedCommands().isEmpty) + #expect(try await operation.value == DOM.Node.ID("selected-node")) + await runtime.close() } @Test -func inspectorInspectIgnoresNonNodeRemoteObject() async throws { +func rawElementPickerResolvesInspectorEventThroughProductionCommandPath() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - let recorder = EventRecorder() - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - if let event = await iterator.next() { - await recorder.record(event) + try await runtime.page.dom.withElementPicker { events in + for try await event in events { + guard case let .event(_, nodeID) = event else { + continue + } + return nodeID + } + throw WebInspectorProxyError.closed } } - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) + var command = try await runtime.peer.commands.next() + #expect(command.method == "Inspector.enable") + try await runtime.peer.reply(to: command) + command = try await runtime.peer.commands.next() + #expect(command.method == "Inspector.initialized") + try await runtime.peer.reply(to: command) + command = try await runtime.peer.commands.next() + #expect(command.method == "DOM.setInspectModeEnabled") + try await runtime.peer.reply(to: command) - await runtime.backend.emit( - .inspect( - Runtime.RemoteObject( - id: Runtime.RemoteObject.ID("function-object"), - kind: .function - ), - hints: .object([:]), - origin: nil - ), - target: target + try await runtime.peer.emitTargetEvent( + targetID: "page-main", + method: "Inspector.inspect", + parameters: try jsonObject( + #"{"object":{"type":"object","subtype":"node","objectId":"remote-node"},"hints":{}}"# + ) ) - try await Task.sleep(for: .milliseconds(100)) - #expect(await recorder.value() == nil) - eventTask.cancel() - #expect(await runtime.backend.recordedCommands().isEmpty) -} - -@Test -func networkEventsAreSeparatedByTarget() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let firstTarget = try await runtime.proxy.waitForCurrentPage() - let secondTarget = await runtime.proxy.installTargetForTesting(kind: .frame) - - let firstEventTask = Task { - var iterator = firstTarget.network.events.makeAsyncIterator() - return await iterator.next() - } - let secondEventTask = Task { - var iterator = secondTarget.network.events.makeAsyncIterator() - return await iterator.next() - } - - try await runtime.backend.waitForSubscribers(domain: "Network", target: firstTarget.id, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Network", target: secondTarget.id, count: 1) - - await runtime.backend.emit( - .responseReceived( - id: Network.Request.ID("first-request"), - response: Network.Response(status: 200), - resourceType: .document, - timestamp: 1 - ), - target: firstTarget.id - ) - await runtime.backend.emit( - .responseReceived( - id: Network.Request.ID("second-request"), - response: Network.Response(status: 201), - resourceType: .xhr, - timestamp: 2 - ), - target: secondTarget.id - ) - - let firstEvent = try #require(try await value(of: firstEventTask)) - let secondEvent = try #require(try await value(of: secondEventTask)) - - guard case let .responseReceived(firstID, _, firstType, firstTimestamp) = firstEvent else { - Issue.record("Expected first target to receive Network.responseReceived.") - return - } - #expect(firstID == Network.Request.ID("first-request")) - #expect(firstType == .document) - #expect(firstTimestamp == 1) - - guard case let .responseReceived(secondID, _, secondType, secondTimestamp) = secondEvent else { - Issue.record("Expected second target to receive Network.responseReceived.") - return - } - #expect(secondID == Network.Request.ID("second-request")) - #expect(secondType == .xhr) - #expect(secondTimestamp == 2) -} - -@Test -func networkEventsAreSeparatedByRouteForStableTargetID() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let originalTarget = try await runtime.proxy.waitForCurrentPage() - let retargetedHandle = WebInspectorTarget( - id: originalTarget.id, - kind: originalTarget.kind, - frameID: originalTarget.frameID, - isProvisional: originalTarget.isProvisional, - proxy: runtime.proxy, - route: RoutingTargetID("retargeted-route") + command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "DOM.requestNode") + #expect(try command.parameters.decode(RequestNodeParameters.self).objectId == "remote-node") + try await runtime.peer.reply( + to: command, + with: try jsonObject(#"{"nodeId":"selected-node"}"#) ) - let originalEventTask = Task { - var iterator = originalTarget.network.events.makeAsyncIterator() - return await iterator.next() - } - let retargetedEventTask = Task { - var iterator = retargetedHandle.network.events.makeAsyncIterator() - return await iterator.next() - } - - try await runtime.backend.waitForSubscribers(domain: "Network", target: originalTarget, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Network", target: retargetedHandle, count: 1) - - await runtime.backend.emit( - .responseReceived( - id: Network.Request.ID("retargeted-request"), - response: Network.Response(status: 202), - resourceType: .fetch, - timestamp: 3 - ), - target: retargetedHandle - ) - await runtime.backend.emit( - .responseReceived( - id: Network.Request.ID("original-request"), - response: Network.Response(status: 200), - resourceType: .document, - timestamp: 4 - ), - target: originalTarget - ) - - let originalEvent = try #require(try await value(of: originalEventTask)) - let retargetedEvent = try #require(try await value(of: retargetedEventTask)) + command = try await runtime.peer.commands.next() + #expect(command.method == "DOM.setInspectModeEnabled") + try await runtime.peer.reply(to: command) + command = try await runtime.peer.commands.next() + #expect(command.method == "Inspector.disable") + try await runtime.peer.reply(to: command) - guard case let .responseReceived(originalID, _, _, _) = originalEvent else { - Issue.record("Expected original route to receive Network.responseReceived.") - return - } - #expect(originalID == Network.Request.ID("original-request")) - - guard case let .responseReceived(retargetedID, _, _, _) = retargetedEvent else { - Issue.record("Expected retargeted route to receive Network.responseReceived.") - return - } - #expect(retargetedID == Network.Request.ID("retargeted-request")) + #expect(try await eventTask.value == DOM.Node.ID("selected-node")) + await runtime.close() } @Test -func networkLoadingFinishedCarriesTerminalMetadata() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() - } - - try await runtime.backend.waitForSubscribers(domain: "Network", target: target, count: 1) - - await runtime.backend.emit( - .loadingFinished( - id: Network.Request.ID("terminal-request"), - timestamp: 8, - sourceMapURL: "terminal.js.map", - metrics: Network.Metrics(encodedDataLength: 256, decodedBodyLength: 512) - ), - target: target - ) - - let event = try #require(try await value(of: eventTask)) - guard case let .loadingFinished(id, timestamp, sourceMapURL, metrics) = event else { - Issue.record("Expected Network.loadingFinished.") - return - } - #expect(id == Network.Request.ID("terminal-request")) - #expect(timestamp == 8) - #expect(sourceMapURL == "terminal.js.map") - #expect(metrics?.encodedDataLength == 256) - #expect(metrics?.decodedBodyLength == 512) -} - -@Test -func pageReloadDispatchesToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.backend.enqueue((), for: "Page", method: "reload") - - try await target.page.reload() - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == target.id) - #expect(command.route == target.route) - #expect(command.domain == "Page") - #expect(command.method == "reload") - let payload = try #require(command.payload.cast(as: Page.ReloadPayload.self)) - #expect(payload.ignoringCache == false) -} - -@Test -func networkEnableAndDisableDispatchToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.backend.enqueue((), for: "Network", method: "enable") - await runtime.backend.enqueue((), for: "Network", method: "disable") - - try await target.network.enable() - try await target.network.disable() - - let commands = await runtime.backend.recordedCommands() - let enable = try #require(commands.first) - #expect(enable.targetID == target.id) - #expect(enable.route == target.route) - #expect(enable.domain == "Network") - #expect(enable.method == "enable") - #expect(enable.payload.cast(as: Network.EnablePayload.self) != nil) - - let disable = try #require(commands.dropFirst().first) - #expect(disable.targetID == target.id) - #expect(disable.route == target.route) - #expect(disable.domain == "Network") - #expect(disable.method == "disable") - #expect(disable.payload.cast(as: Network.DisablePayload.self) != nil) -} - -@Test -func consoleEnableAndDisableDispatchToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.backend.enqueue((), for: "Console", method: "enable") - await runtime.backend.enqueue((), for: "Console", method: "disable") - - try await target.console.enable() - try await target.console.disable() - - let commands = await runtime.backend.recordedCommands() - let enable = try #require(commands.first) - #expect(enable.targetID == target.id) - #expect(enable.route == target.route) - #expect(enable.domain == "Console") - #expect(enable.method == "enable") - #expect(enable.payload.cast(as: Console.EnablePayload.self) != nil) - - let disable = try #require(commands.dropFirst().first) - #expect(disable.targetID == target.id) - #expect(disable.route == target.route) - #expect(disable.domain == "Console") - #expect(disable.method == "disable") - #expect(disable.payload.cast(as: Console.DisablePayload.self) != nil) -} - -@Test -func runtimeEnableAndDisableDispatchToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.backend.enqueue((), for: "Runtime", method: "enable") - await runtime.backend.enqueue((), for: "Runtime", method: "disable") - - try await target.runtime.enable() - try await target.runtime.disable() - - let commands = await runtime.backend.recordedCommands() - let enable = try #require(commands.first) - #expect(enable.targetID == target.id) - #expect(enable.route == target.route) - #expect(enable.domain == "Runtime") - #expect(enable.method == "enable") - #expect(enable.payload.cast(as: Runtime.EnablePayload.self) != nil) - - let disable = try #require(commands.dropFirst().first) - #expect(disable.targetID == target.id) - #expect(disable.route == target.route) - #expect(disable.domain == "Runtime") - #expect(disable.method == "disable") - #expect(disable.payload.cast(as: Runtime.DisablePayload.self) != nil) -} - -@Test -func cssEnableAndDisableDispatchToTargetRoute() async throws { - let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - - await runtime.backend.enqueue((), for: "CSS", method: "enable") - await runtime.backend.enqueue((), for: "CSS", method: "disable") - - try await target.css.enable() - try await target.css.disable() - - let commands = await runtime.backend.recordedCommands() - let enable = try #require(commands.first) - #expect(enable.targetID == target.id) - #expect(enable.route == target.route) - #expect(enable.domain == "CSS") - #expect(enable.method == "enable") - #expect(enable.payload.cast(as: CSS.EnablePayload.self) != nil) - - let disable = try #require(commands.dropFirst().first) - #expect(disable.targetID == target.id) - #expect(disable.route == target.route) - #expect(disable.domain == "CSS") - #expect(disable.method == "disable") - #expect(disable.payload.cast(as: CSS.DisablePayload.self) != nil) -} - -@Test -func scopedCSSStyleSheetIDDispatchesToOwningTarget() async throws { +func rawPeerPreservesScopedCSSRoutingAndStripsWireIdentifierScope() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() let page = try await runtime.proxy.waitForCurrentPage() - let frameTarget = await runtime.proxy.installTargetForTesting(kind: .frame) + try await runtime.peer.createTarget(.init( + id: "frame-target", + type: "frame", + frameID: "child-frame", + parentFrameID: "main-frame" + )) let styleSheetID = CSS.StyleSheet.ID( "frame-sheet", - scopedToTargetRawValue: frameTarget.id.rawValue + scopedToTargetRawValue: "frame-target" ) - await runtime.backend.enqueue((), for: "CSS", method: "setStyleSheetText") - - try await page.css.setStyleSheetText(styleSheetID, text: "body { color: red; }") - - let commands = await runtime.backend.recordedCommands() - let command = try #require(commands.first) - #expect(command.targetID == frameTarget.id) - #expect(command.route == RoutingTargetID(frameTarget.id.rawValue)) - #expect(command.domain == "CSS") - #expect(command.method == "setStyleSheetText") - let payload = try #require(command.payload.cast(as: CSS.SetStyleSheetTextPayload.self)) - #expect(payload.id == styleSheetID) - #expect(payload.id.unscopedRawValue == "frame-sheet") + let operation = Task { + try await page.css.setStyleSheetText( + styleSheetID, + text: "body { color: red; }" + ) + } + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("frame-target")) + #expect(command.method == "CSS.setStyleSheetText") + let parameters = try command.parameters.decode(SetStyleSheetTextParameters.self) + #expect(parameters.styleSheetId == "frame-sheet") + #expect(parameters.text == "body { color: red; }") + try await runtime.peer.reply(to: command) + try await operation.value + await runtime.close() } @Test -func scopedRuntimeIDsDispatchToOwningTarget() async throws { +func rawPeerPreservesScopedRuntimeRoutingAndStripsWireIdentifierScope() async throws { let runtime = try await WebInspectorProxyTestRuntime.start() let page = try await runtime.proxy.waitForCurrentPage() - let frameTarget = await runtime.proxy.installTargetForTesting(kind: .frame) + try await runtime.peer.createTarget(.init( + id: "frame-target", + type: "frame", + frameID: "child-frame", + parentFrameID: "main-frame" + )) let contextID = Runtime.ExecutionContext.ID( "frame-context", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - let objectID = Runtime.RemoteObject.ID( - "frame-object", - scopedToTargetRawValue: frameTarget.id.rawValue - ) - - await runtime.backend.enqueue( - Runtime.EvaluationResult(object: Runtime.RemoteObject(id: objectID, kind: .object)), - for: "Runtime", - method: "evaluate" - ) - _ = try await page.runtime.evaluate("window", in: contextID) - - await runtime.backend.enqueue( - [Runtime.PropertyDescriptor(name: "answer")], - for: "Runtime", - method: "getProperties" + scopedToTargetRawValue: "frame-target" ) - _ = try await page.runtime.properties(of: objectID) - await runtime.backend.enqueue( - Runtime.ObjectPreview(kind: .object, description: "preview"), - for: "Runtime", - method: "getPreview" - ) - _ = try await page.runtime.preview(of: objectID) - - await runtime.backend.enqueue( - [Runtime.CollectionEntry(value: Runtime.RemoteObject(id: nil, kind: .string, value: .string("value")))], - for: "Runtime", - method: "getCollectionEntries" + let operation = Task { + try await page.runtime.evaluate("window", in: contextID) + } + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("frame-target")) + #expect(command.method == "Runtime.evaluate") + let parameters = try command.parameters.decode(EvaluateParameters.self) + #expect(parameters.expression == "window") + #expect(parameters.contextId == "frame-context") + try await runtime.peer.reply( + to: command, + with: try jsonObject( + #"{"result":{"type":"object","objectId":"frame-object"}}"# + ) ) - _ = try await page.runtime.collectionEntries(of: objectID) - - await runtime.backend.enqueue((), for: "Runtime", method: "releaseObject") - try await page.runtime.releaseObject(objectID) - - let runtimeCommands = await runtime.backend.recordedCommands() - .filter { $0.domain == "Runtime" } - #expect(runtimeCommands.count == 5) - #expect(runtimeCommands.allSatisfy { $0.targetID == frameTarget.id }) - #expect(runtimeCommands.allSatisfy { $0.route == RoutingTargetID(frameTarget.id.rawValue) }) - let evaluate = try #require(runtimeCommands.first { $0.method == "evaluate" }) - #expect(evaluate.payload.cast(as: Runtime.EvaluatePayload.self)?.context == contextID) - - let properties = try #require(runtimeCommands.first { $0.method == "getProperties" }) - #expect(properties.payload.cast(as: Runtime.GetPropertiesPayload.self)?.object == objectID) - - let preview = try #require(runtimeCommands.first { $0.method == "getPreview" }) - #expect(preview.payload.cast(as: Runtime.GetPreviewPayload.self)?.object == objectID) - - let entries = try #require(runtimeCommands.first { $0.method == "getCollectionEntries" }) - #expect(entries.payload.cast(as: Runtime.GetCollectionEntriesPayload.self)?.object == objectID) - - let release = try #require(runtimeCommands.first { $0.method == "releaseObject" }) - #expect(release.payload.cast(as: Runtime.ReleaseObjectPayload.self)?.id == objectID) + let result = try await operation.value + #expect(result.object.id == Runtime.RemoteObject.ID( + "frame-object", + scopedToTargetRawValue: "frame-target" + )) + await runtime.close() } -private struct TimedOut: Error {} +private struct RequestNodeParameters: Decodable, Sendable { + let objectId: String +} -private actor EventRecorder { - private var recordedValue: Element? +private struct SetStyleSheetTextParameters: Decodable, Sendable { + let styleSheetId: String + let text: String +} - func record(_ value: Element) { - recordedValue = value - } +private struct EvaluateParameters: Decodable, Sendable { + let expression: String + let contextId: String +} - func value() -> Element? { - recordedValue - } +private func jsonObject(_ json: String) throws -> WebInspectorTestJSONObject { + try WebInspectorTestJSONObject(json: json) } -private func value( - of task: Task, - timeout: Duration = .seconds(1) -) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - await task.value +private func responseReceivedParameters( + requestID: String, + status: Int, + resourceType: String, + timestamp: Double +) throws -> WebInspectorTestJSONObject { + try jsonObject( + """ + { + "requestId": "\(requestID)", + "response": {"status": \(status)}, + "type": "\(resourceType)", + "timestamp": \(timestamp) } - group.addTask { - try await Task.sleep(for: timeout) - throw TimedOut() - } - guard let value = try await group.next() else { - throw TimedOut() - } - group.cancelAll() - return value - } + """ + ) } diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift index abc9fe0c..c91232e2 100644 --- a/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift +++ b/Tests/WebInspectorProxyKitTests/WebInspectorProxyTransportCommandBackendTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -import WebInspectorProxyKit import WebInspectorTestSupport +@testable import WebInspectorProxyKit private let transportCommandBackendWaitTimeout: Duration = .milliseconds(750) @@ -10,7 +10,8 @@ func transportCommandBackendDispatchesPageReloadThroughTargetRoute() async throw let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let reloadTask = Task { try await target.page.reload() @@ -35,7 +36,8 @@ func transportCommandBackendDecodesDOMRequestNodeResult() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let requestNodeTask = Task { try await target.dom.requestNode(forRemoteObject: Runtime.RemoteObject.ID("remote-node")) @@ -61,7 +63,8 @@ func transportCommandBackendPreservesDOMRequestChildNodesRecursiveDepth() async let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let requestChildrenTask = Task { try await target.dom.requestChildNodes(DOM.Node.ID("document"), depth: -1) @@ -88,7 +91,8 @@ func transportCommandBackendEncodesDOMEditingCommandsAndDecodesAttributes() asyn let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let attributesTask = Task { try await target.dom.attributes(of: DOM.Node.ID("42")) @@ -175,7 +179,8 @@ func transportCommandBackendEncodesDOMHighlightAndInspectModeCommands() async th let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let highlightTask = Task { try await target.dom.highlightNode(DOM.Node.ID("42")) @@ -249,7 +254,8 @@ func transportCommandBackendDecodesDOMDocumentResult() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let documentTask = Task { try await target.dom.getDocument() @@ -284,7 +290,8 @@ func transportCommandBackendEncodesAndDecodesCSSStyleCommands() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) let matchedStylesTask = Task { try await target.css.matchedStyles(for: DOM.Node.ID("42")) @@ -547,48 +554,49 @@ func transportBackedProxyMaterializesCurrentPageFromTransportRegistry() async th } @Test -func transportBackedProxyCloseDetachesTransportAndFinishesEventStreams() async throws { +func transportBackedProxyWaitUntilClosedSuspendsUntilClose() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() + let waitTask = Task { + try await proxy.waitUntilClosed() } - await waitForEventSubscription(target, domain: .dom) + await proxy.waitForCloseWaiterForTesting() + #expect(await backend.isDetached() == false) + await proxy.close() + try await waitTask.value #expect(await backend.isDetached()) - #expect(try await value(of: eventTask) == nil) } @Test -func transportBackedProxyWaitUntilClosedSuspendsUntilClose() async throws { +func nativeFatalCallbackPropagatesThroughProxyTerminalAPI() async throws { let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport) - let proxy = try await WebInspectorProxy(transport: transport) + let core = ConnectionCore(backend: backend, responseTimeout: .milliseconds(750)) + let receiver = TransportReceiver() + receiver.setCore(core) + await installPageTarget(in: core) + let proxy = try await WebInspectorProxy(transport: core) + let page = try await proxy.waitForCurrentPage() - let waitTask = Task { + receiver.fail("native frontend disconnected") + + await #expect(throws: WebInspectorProxyError.disconnected("native frontend disconnected")) { try await proxy.waitUntilClosed() } - - await proxy.waitForCloseWaiterForTesting() - #expect(await backend.isDetached() == false) - - await proxy.close() - - try await waitTask.value + await #expect(throws: WebInspectorProxyError.disconnected("native frontend disconnected")) { + try await page.page.reload() + } #expect(await backend.isDetached()) } @Test func proxyWaitUntilClosedReturnsImmediatelyAfterClose() async throws { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) await proxy.close() @@ -598,10 +606,104 @@ func proxyWaitUntilClosedReturnsImmediatelyAfterClose() async throws { try await throwingValue(of: waitTask, timeout: .milliseconds(100)) } +@Test +func proxyHandleDeallocatesAfterExplicitClose() async { + weak var weakProxy: WebInspectorProxy? + + do { + let proxy = WebInspectorProxy(localStateOnly: ()) + weakProxy = proxy + await proxy.close() + } + + #expect(weakProxy == nil) +} + +@Test +func droppingOpenProxyHandleReachesConnectionCoreDeinit() { + weak var weakProxy: WebInspectorProxy? + + do { + let proxy = WebInspectorProxy(localStateOnly: ()) + weakProxy = proxy + } + + #expect(weakProxy == nil) +} + +@Test +func pageTargetAndDomainHandlesDoNotOwnTheProxyLifecycle() async { + weak var weakProxy: WebInspectorProxy? + var page: WebInspectorPage? + var dom: DOM? + + do { + let proxy = WebInspectorProxy(localStateOnly: ()) + weakProxy = proxy + page = proxy.page + dom = page?.dom + } + + #expect(weakProxy == nil) + await #expect(throws: WebInspectorProxyError.closed) { + _ = try await page?.generation + } + await #expect(throws: WebInspectorProxyError.closed) { + _ = try await dom?.getDocument() + } + await #expect(throws: WebInspectorProxyError.closed) { + try await dom?.withEvents { _ in } + } +} + +@Test +func pendingCapabilitySendDoesNotRetainDroppedConnectionCore() async throws { + let backend = SuspendedSendTransportBackend() + weak var weakTransport: TransportSession? + + do { + let transport = TransportSession(backend: backend, responseTimeout: nil) + weakTransport = transport + await installPageTarget(in: transport) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# + ) + let proxy = try await WebInspectorProxy(transport: transport) + let frame = WebInspectorTarget( + id: WebInspectorTarget.ID("frame-target"), + kind: .frame, + frameID: FrameID("child-frame"), + isProvisional: false, + proxy: proxy, + route: RoutingTargetID("frame-target") + ) + let scopeTask = Task { + do { + try await frame.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.pageUnavailable { + return .pageUnavailable + } catch { + return .other(String(describing: error)) + } + } + + await backend.waitUntilSendStarted() + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"frame-target"}}"# + ) + #expect(try await value(of: scopeTask) == .pageUnavailable) + await transport.waitForEventScopeCountForTesting(0) + } + + #expect(weakTransport == nil) + await backend.releaseSend() +} + @Test func proxyWaitUntilClosedWaitsForInFlightCloseConnection() async throws { let closeGate = CloseConnectionGate() - let proxy = WebInspectorProxy(closeConnection: { + let proxy = WebInspectorProxy(localStateOnly: (), closeConnection: { await closeGate.waitUntilReleased() }) @@ -626,7 +728,7 @@ func proxyWaitUntilClosedWaitsForInFlightCloseConnection() async throws { @Test func proxyWaitUntilClosedCancellationRemovesWaiter() async throws { - let proxy = WebInspectorProxy() + let proxy = WebInspectorProxy(localStateOnly: ()) let waitTask = Task { try await proxy.waitUntilClosed() @@ -721,95 +823,6 @@ func transportBackedCurrentPageRouteFollowsCommittedMainPageTarget() async throw try await secondReloadTask.value } -@Test -func transportBackendDeliversCurrentPageTargetCommitLifecycleAfterRetarget() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget( - in: transport, - targetID: ProtocolTarget.ID("page-old"), - frameID: nil - ) - let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.frameID == nil) - - let eventTask = Task { - var iterator = target.lifecycleEvents.makeAsyncIterator() - while let event = await iterator.next() { - if case .didCommitProvisionalTarget = event { - return event - } - } - return nil - } - - await waitForEventSubscription(target, domain: .target) - await installPageTarget( - in: transport, - targetID: ProtocolTarget.ID("page-new"), - frameID: "new-main-frame" - ) - await transport.receiveRootMessage( - #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-old","newTargetId":"page-new"}}"# - ) - - let event = try #require(try await value(of: eventTask)) - guard case let .didCommitProvisionalTarget(commit) = event else { - Issue.record("Expected Target.didCommitProvisionalTarget lifecycle event.") - return - } - #expect(commit.oldTargetID == WebInspectorTarget.ID.currentPage) - #expect(commit.newTarget.id == WebInspectorTarget.ID.currentPage) - guard case .page = commit.newTarget.kind else { - Issue.record("Expected committed target to remain a page.") - return - } - #expect(commit.newTarget.frameID == FrameID("new-main-frame")) - #expect(commit.newTarget.isProvisional == false) - let cachedTarget = try await proxy.waitForCurrentPage() - #expect(cachedTarget.frameID == FrameID("new-main-frame")) - #expect(cachedTarget.isProvisional == false) - #expect(await transport.snapshot().currentMainPageTargetID == ProtocolTarget.ID("page-new")) -} - -@Test -func transportBackendDeliversCurrentPageTargetDestroyedLifecycle() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-main")) - let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - - let eventTask = Task { - var iterator = target.lifecycleEvents.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .target) - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-main"}}"# - ) - - let event = try #require(try await value(of: eventTask)) - guard case let .targetDestroyed(targetID) = event else { - Issue.record("Expected Target.targetDestroyed lifecycle event.") - return - } - #expect(targetID == .currentPage) - #expect(await transport.snapshot().currentMainPageTargetID == nil) - #expect(await proxy.currentPage == nil) - - let replacementTask = Task { - try await proxy.waitForCurrentPage() - } - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-replacement")) - let replacement = try await throwingValue(of: replacementTask) - #expect(replacement.id == .currentPage) - #expect(replacement.route == .currentPage) - #expect(await transport.snapshot().currentMainPageTargetID == ProtocolTarget.ID("page-replacement")) -} - @Test func transportBackedWaitForCurrentPageRefreshesDestroyedTargetWithoutLifecycleSubscription() async throws { let backend = FakeTransportBackend() @@ -837,184 +850,7 @@ func transportBackedWaitForCurrentPageRefreshesDestroyedTargetWithoutLifecycleSu } @Test -func transportBackendDeliversCurrentPagePageFrameLifecycle() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-main")) - let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - - let eventTask = Task { - var iterator = target.lifecycleEvents.makeAsyncIterator() - let first = await iterator.next() - let second = await iterator.next() - return [first, second].compactMap { $0 } - } - - await waitForEventSubscription(target, domain: .page) - await transport.receiveRootMessage( - #"{"method":"Page.frameNavigated","params":{"frame":{"id":"main-frame","loaderId":"loader-1","name":"Main","url":"https://example.test/","securityOrigin":"https://example.test","mimeType":"text/html"}}}"# - ) - await transport.receiveRootMessage( - #"{"method":"Page.frameDetached","params":{"frameId":"child-frame"}}"# - ) - - let events = try await value(of: eventTask) - #expect(events.count == 2) - guard case let .frameNavigated(frame) = events[0] else { - Issue.record("Expected Page.frameNavigated lifecycle event.") - return - } - #expect(frame.id == FrameID("main-frame")) - #expect(frame.parentID == nil) - #expect(frame.loaderID == "loader-1") - #expect(frame.name == "Main") - #expect(frame.url == "https://example.test/") - #expect(frame.securityOrigin == "https://example.test") - #expect(frame.mimeType == "text/html") - - guard case let .frameDetached(frameID) = events[1] else { - Issue.record("Expected Page.frameDetached lifecycle event.") - return - } - #expect(frameID == FrameID("child-frame")) -} - -@Test -func transportBackendDecodesRootScopedDOMDocumentUpdatedForCurrentPage() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .dom) - await transport.receiveRootMessage(#"{"method":"DOM.documentUpdated","params":{}}"#) - - let event = try #require(try await value(of: eventTask)) - guard case .documentUpdated = event else { - Issue.record("Expected DOM.documentUpdated.") - return - } -} - -@Test -func transportBackendDecodesDOMShadowAndPseudoEventsForTargetRoute() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - var events: [DOM.Event] = [] - for _ in 0..<4 { - if let event = await iterator.next() { - events.append(event) - } - } - return events - } - - await waitForEventSubscription(target, domain: .dom) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "DOM.shadowRootPushed", - params: ##"{"hostId":1,"root":{"nodeId":2,"nodeType":11,"nodeName":"#document-fragment","localName":"","nodeValue":"","childNodeCount":0}}"## - ) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "DOM.shadowRootPopped", - params: #"{"hostId":1,"rootId":2}"# - ) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "DOM.pseudoElementAdded", - params: #"{"parentId":1,"pseudoElement":{"nodeId":3,"nodeType":1,"nodeName":"::before","localName":"","nodeValue":"","childNodeCount":0,"pseudoType":"before"}}"# - ) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "DOM.pseudoElementRemoved", - params: #"{"parentId":1,"pseudoElementId":3}"# - ) - - let events = try await value(of: eventTask) - #expect(events.count == 4) - guard case let .shadowRootPushed(host, root) = events[0] else { - Issue.record("Expected DOM.shadowRootPushed.") - return - } - #expect(host == DOM.Node.ID("1")) - #expect(root.id == DOM.Node.ID("2")) - guard case let .shadowRootPopped(poppedHost, poppedRoot) = events[1] else { - Issue.record("Expected DOM.shadowRootPopped.") - return - } - #expect(poppedHost == DOM.Node.ID("1")) - #expect(poppedRoot == DOM.Node.ID("2")) - guard case let .pseudoElementAdded(parent, element) = events[2] else { - Issue.record("Expected DOM.pseudoElementAdded.") - return - } - #expect(parent == DOM.Node.ID("1")) - #expect(element.id == DOM.Node.ID("3")) - guard case let .pseudoElementRemoved(removedParent, removedElement) = events[3] else { - Issue.record("Expected DOM.pseudoElementRemoved.") - return - } - #expect(removedParent == DOM.Node.ID("1")) - #expect(removedElement == DOM.Node.ID("3")) -} - -@Test -func transportBackendNormalizesInspectorInspectToDOMInspectEvent() async throws { - let backend = FakeTransportBackend() - let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() - } - - await waitForEventSubscription(target, domain: .dom) - await waitForEventSubscription(target, domain: .inspector) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Inspector.inspect", - params: #"{"object":{"objectId":"remote-node","type":"object","subtype":"node"},"hints":{}}"# - ) - - let requestNode = try await waitForTargetMessage(backend, method: "DOM.requestNode") - #expect(requestNode.targetIdentifier == ProtocolTarget.ID("page-main")) - #expect(try messageParameters(requestNode.message)["objectId"] as? String == "remote-node") - await receiveTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try messageID(requestNode.message), - result: #"{"nodeId":42}"# - ) - - let event = try #require(try await value(of: eventTask)) - guard case let .inspect(nodeID) = event else { - Issue.record("Expected Inspector.inspect to normalize to DOM.inspect.") - return - } - #expect(nodeID == DOM.Node.ID("42")) -} - -@Test -func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async throws { +func transportBackendProjectsFrameDOMEventsAndRoutesScopedCommands() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) @@ -1024,53 +860,6 @@ func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async let proxy = try await WebInspectorProxy(transport: transport) let target = try await proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - let first = await iterator.next() - let second = await iterator.next() - return [first, second].compactMap { $0 } - } - - await waitForEventSubscription(target, domain: .dom) - await waitForEventSubscription(target, domain: .inspector) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "Inspector.inspect", - params: #"{"object":{"objectId":"remote-frame-node","type":"object","subtype":"node"},"hints":{}}"# - ) - - let requestNode = try await waitForTargetMessage(backend, method: "DOM.requestNode") - #expect(requestNode.targetIdentifier == ProtocolTarget.ID("page-main")) - #expect(try messageParameters(requestNode.message)["objectId"] as? String == "remote-frame-node") - await receiveTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try messageID(requestNode.message), - result: #"{"nodeId":42}"# - ) - - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "DOM.setChildNodes", - params: #"{"parentId":42,"nodes":[{"nodeId":43,"nodeType":1,"nodeName":"SPAN","localName":"span","nodeValue":"","childNodeCount":0}]}"# - ) - - let events = try await value(of: eventTask) - #expect(events.count == 2) - guard case let .inspect(nodeID)? = events.first else { - Issue.record("Expected frame Inspector.inspect to normalize to DOM.inspect.") - return - } - #expect(nodeID == DOM.Node.ID("42", scopedToTargetRawValue: "frame-target")) - guard case let .setChildNodes(parentID, nodes)? = events.last else { - Issue.record("Expected frame DOM.setChildNodes to be projected into the current page DOM stream.") - return - } - #expect(parentID == DOM.Node.ID("42", scopedToTargetRawValue: "frame-target")) - #expect(nodes.first?.id == DOM.Node.ID("43", scopedToTargetRawValue: "frame-target")) - let frameNodeID = DOM.Node.ID("42", scopedToTargetRawValue: "frame-target") let frameAttributesTask = Task { try await target.dom.attributes(of: frameNodeID) @@ -1291,314 +1080,422 @@ func transportBackendNormalizesFrameInspectorInspectForCurrentPageRoute() async ) #expect(try await frameSetGroupingHeaderTask.value.text == "@media (min-width: 600px)") - let cssEventTask = Task { - var iterator = target.css.events.makeAsyncIterator() - return await iterator.next() - } - await waitForEventSubscription(target, domain: .css) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "CSS.styleSheetChanged", - params: #"{"styleSheetId":"frame-sheet"}"# - ) - let cssEvent = try #require(try await value(of: cssEventTask)) - guard case let .styleSheetChanged(styleSheetID) = cssEvent else { - Issue.record("Expected frame CSS.styleSheetChanged to be projected into the current page CSS stream.") - return - } - #expect(styleSheetID.targetScopeRawValue == "frame-target") - #expect(styleSheetID.unscopedRawValue == "frame-sheet") } @Test -func transportBackendNormalizesParentlessFrameInspectorInspectForCurrentPageRoute() async throws { +func transportBackendRoutesRequestNodeThroughPageDOMAgentWithoutInventingFrameScope() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-page-target","type":"page","frameId":"child-frame","isProvisional":false}}}"# + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() + let target = proxy.frameTarget(id: WebInspectorTarget.ID("frame-target")) - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - return await iterator.next() + let requestNodeTask = Task { + try await target.dom.requestNode( + forRemoteObject: Runtime.RemoteObject.ID("frame-object", scopedToTargetRawValue: "frame-target") + ) } - - await waitForEventSubscription(target, domain: .dom) - await waitForEventSubscription(target, domain: .inspector) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("frame-page-target"), - method: "Inspector.inspect", - params: #"{"object":{"objectId":"remote-frame-node","type":"object","subtype":"node"},"hints":{}}"# - ) - let requestNode = try await waitForTargetMessage(backend, method: "DOM.requestNode") #expect(requestNode.targetIdentifier == ProtocolTarget.ID("page-main")) - #expect(try messageParameters(requestNode.message)["objectId"] as? String == "remote-frame-node") + #expect(try messageParameters(requestNode.message)["objectId"] as? String == "frame-object") await receiveTargetReply( transport, targetID: requestNode.targetIdentifier, messageID: try messageID(requestNode.message), - result: #"{"nodeId":42}"# + result: #"{"nodeId":"frame-node"}"# ) - - let event = try #require(try await value(of: eventTask)) - guard case let .inspect(nodeID) = event else { - Issue.record("Expected parentless frame Inspector.inspect to normalize to DOM.inspect.") - return - } - #expect(nodeID == DOM.Node.ID("42", scopedToTargetRawValue: "frame-page-target")) + let nodeID = try await requestNodeTask.value + #expect(nodeID == DOM.Node.ID("frame-node")) + #expect(nodeID.targetScopeRawValue == nil) } @Test -func transportBackendDecodesNetworkResponseEventForTargetRoute() async throws { +func transportBackendForwardsBackendResourceIdentifierToResponseBodyCommand() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = try await proxy.waitForCurrentPage() + let id = Network.Request.ID("cached-request") + let backendResourceIdentifier = Network.BackendResourceID( + sourceProcessID: "77", + resourceID: "1234" + ) - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let bodyTask = Task { + try await target.network.responseBody( + for: id, + backendResourceIdentifier: backendResourceIdentifier + ) } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( + let bodyCommand = try await waitForTargetMessage(backend, method: "Network.getResponseBody") + let parameters = try messageParameters(bodyCommand.message) + #expect(parameters["requestId"] as? String == "cached-request") + let identifier = try #require(parameters["backendResourceIdentifier"] as? [String: Any]) + #expect(identifier["sourceProcessID"] as? String == "77") + #expect(identifier["resourceID"] as? String == "1234") + await receiveTargetReply( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.responseReceived", - params: #"{"requestId":"request-1","type":"Document","response":{"url":"https://example.test/","status":200,"statusText":"OK","mimeType":"text/html","headers":{"content-type":"text/html"},"source":"network"},"timestamp":12.5}"# + targetID: bodyCommand.targetIdentifier, + messageID: try messageID(bodyCommand.message), + result: #"{"body":"cached body","base64Encoded":false}"# ) - - let event = try #require(try await value(of: eventTask)) - guard case let .responseReceived(id, response, resourceType, timestamp) = event else { - Issue.record("Expected Network.responseReceived.") - return - } - #expect(id == Network.Request.ID("request-1")) - #expect(response.url == "https://example.test/") - #expect(response.status == 200) - #expect(response.headers["content-type"] == "text/html") - #expect(response.source == Network.Source(rawValue: "network")) - #expect(resourceType == .document) - #expect(timestamp == 12.5) + let body = try await bodyTask.value + #expect(body.data == "cached body") } @Test -func transportBackendDecodesNetworkResponseEventWithoutType() async throws { +func transportCommandBackendDecodesRuntimeEvaluationResult() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) + let proxy = try await WebInspectorProxy(transport: transport) + let target = pageTarget(proxy: proxy) - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let evaluateTask = Task { + try await target.runtime.evaluate("document.title", in: Runtime.ExecutionContext.ID("7")) } - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( + let sent = try await waitForTargetMessage(backend, method: "Runtime.evaluate") + #expect(sent.targetIdentifier == ProtocolTarget.ID("page-main")) + #expect(try messageMethod(sent.message) == "Runtime.evaluate") + let parameters = try messageParameters(sent.message) + #expect(parameters["expression"] as? String == "document.title") + #expect((parameters["contextId"] as? NSNumber)?.intValue == 7) + + await receiveTargetReply( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.responseReceived", - params: #"{"requestId":"request-1","response":{"url":"https://example.test/","status":200,"statusText":"OK","mimeType":"text/html","headers":{"content-type":"text/html"},"source":"network"},"timestamp":12.5}"# + targetID: sent.targetIdentifier, + messageID: try messageID(sent.message), + result: #"{"result":{"type":"string","value":"Title","description":"Title"},"wasThrown":true,"savedResultIndex":3}"# ) - let event = try #require(try await value(of: eventTask)) - guard case let .responseReceived(id, response, resourceType, timestamp) = event else { - Issue.record("Expected Network.responseReceived.") - return - } - #expect(id == Network.Request.ID("request-1")) - #expect(response.url == "https://example.test/") - #expect(resourceType == nil) - #expect(timestamp == 12.5) + let result = try await evaluateTask.value + #expect(result.object.kind == .string) + #expect(result.object.value == .string("Title")) + #expect(result.object.description == "Title") + #expect(result.wasThrown == true) + #expect(result.savedResultIndex == 3) } @Test -func transportBackendDeliversFrameNetworkEventsToCurrentPageRoute() async throws { +func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# - ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.route == .currentPage) + let target = pageTarget(proxy: proxy) + let objectID = Runtime.RemoteObject.ID("object-1") - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let propertiesTask = Task { + try await target.runtime.properties(of: objectID) } - - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( + let propertiesCommand = try await waitForTargetMessage(backend, method: "Runtime.getProperties") + await receiveTargetReply( transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "Network.requestWillBeSent", - params: #"{"requestId":"frame-request","frameId":"child-frame","request":{"url":"https://frame.example.test/","method":"GET"},"timestamp":7.5,"type":"Document"}"# + targetID: propertiesCommand.targetIdentifier, + messageID: try messageID(propertiesCommand.message), + result: #"{"properties":[{"name":"answer","value":{"type":"number","value":42,"description":"42"},"writable":true,"isOwn":true},{"name":"accessor","get":{"type":"function","objectId":"getter-1","description":"get answer"},"set":{"type":"undefined","description":"undefined"},"wasThrown":false,"configurable":true,"enumerable":false,"symbol":{"type":"symbol","description":"Symbol(answer)"},"isPrivate":false,"nativeGetter":true}],"internalProperties":[{"name":"[[Prototype]]","value":{"type":"object","objectId":"prototype-1","description":"Object"}}]}"# ) - let event = try #require(try await value(of: eventTask)) - guard case let .requestWillBeSent(id, request, resourceType, _, timestamp) = event else { - Issue.record("Expected current-page route to receive frame Network.requestWillBeSent.") - return + let properties = try await propertiesTask.value + #expect(properties.count == 2) + #expect(properties[0].name == "answer") + #expect(properties[0].value?.kind == .number) + #expect(properties[0].value?.value == .number(42)) + #expect(properties[0].writable == true) + #expect(properties[0].isOwn == true) + #expect(properties[1].name == "accessor") + #expect(properties[1].get?.kind == .function) + #expect(properties[1].get?.id == Runtime.RemoteObject.ID("getter-1")) + #expect(properties[1].set?.kind == .undefined) + #expect(properties[1].wasThrown == false) + #expect(properties[1].configurable == true) + #expect(properties[1].enumerable == false) + #expect(properties[1].symbol?.kind == .symbol) + #expect(properties[1].symbol?.description == "Symbol(answer)") + #expect(properties[1].isPrivate == false) + #expect(properties[1].nativeGetter == true) + + let previewTask = Task { + try await target.runtime.preview(of: objectID) } - #expect(id == Network.Request.ID("frame-request", scopedToTargetRawValue: "frame-target")) - #expect(request.id == id) - #expect(request.url == "https://frame.example.test/") - #expect(request.method == "GET") - #expect(resourceType == .document) - #expect(timestamp == 7.5) + let previewCommand = try await waitForTargetMessage(backend, method: "Runtime.getPreview") + await receiveTargetReply( + transport, + targetID: previewCommand.targetIdentifier, + messageID: try messageID(previewCommand.message), + result: #"{"preview":{"type":"object","subtype":"map","description":"Map(1)","lossless":true,"overflow":false,"properties":[{"name":"size","type":"number","value":"1"}],"entries":[{"key":{"type":"string","description":"key","lossless":true},"value":{"type":"number","description":"42","lossless":true}}],"size":1}}"# + ) - let bodyTask = Task { - try await target.network.responseBody(for: id) + let preview = try await previewTask.value + #expect(preview.kind == .object) + #expect(preview.subtype == Runtime.Subtype(rawValue: "map")) + #expect(preview.description == "Map(1)") + #expect(preview.lossless == true) + #expect(preview.overflow == false) + #expect(preview.properties.first?.name == "size") + #expect(preview.properties.first?.value == "1") + #expect(preview.entries.first?.key == "key") + #expect(preview.entries.first?.value == "42") + #expect(preview.size == 1) + + let entriesTask = Task { + try await target.runtime.collectionEntries(of: objectID) } - let bodyCommand = try await waitForTargetMessage(backend, method: "Network.getResponseBody") - #expect(bodyCommand.targetIdentifier == ProtocolTarget.ID("frame-target")) - #expect(try messageParameters(bodyCommand.message)["requestId"] as? String == "frame-request") + let entriesCommand = try await waitForTargetMessage(backend, method: "Runtime.getCollectionEntries") await receiveTargetReply( transport, - targetID: bodyCommand.targetIdentifier, - messageID: try messageID(bodyCommand.message), - result: #"{"body":"frame body","base64Encoded":false}"# + targetID: entriesCommand.targetIdentifier, + messageID: try messageID(entriesCommand.message), + result: #"{"entries":[{"key":{"type":"string","value":"key","description":"key"},"value":{"type":"object","objectId":"entry-value","description":"entry value"}},{"value":{"type":"number","value":42,"description":"42"}}]}"# ) - let body = try await bodyTask.value - #expect(body.data == "frame body") - #expect(body.base64Encoded == false) + + let entries = try await entriesTask.value + #expect(entries.count == 2) + #expect(entries[0].key?.value == .string("key")) + #expect(entries[0].value.id == Runtime.RemoteObject.ID("entry-value")) + #expect(entries[0].value.description == "entry value") + #expect(entries[1].key == nil) + #expect(entries[1].value.value == .number(42)) } @Test -func transportBackendDropsNonPageDestroyAfterPageDestroyed() async throws { +func pendingRepliesClassifyDirectAndCapabilityOwners() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-main")) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# - ) + await installPageTarget(in: transport) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let recorder = LifecycleEventRecorder() - let consumeTask = Task { - for await event in target.lifecycleEvents { - await recorder.record(event) - } + let directTask = Task { + try await proxy.page.page.reload() } - defer { consumeTask.cancel() } - await waitForEventSubscription(target, domain: .target) - - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"page-main"}}"# + let reload = try await waitForTargetMessage(backend, method: "Page.reload") + let reloadPendingKey = TransportSession.PendingKey.target( + TransportSession.ReplyKey( + targetID: reload.targetIdentifier, + commandID: try messageID(reload.message) + ) ) - // With no current page registered, a frame teardown must not surface as a - // current-page destruction; the replacement's creation event marks the - // point past which the frame destroy would have been delivered. - await transport.receiveRootMessage( - #"{"method":"Target.targetDestroyed","params":{"targetId":"frame-target"}}"# + let reloadPurposes = await transport.pendingReplyPurposes() + guard case let .direct(bindingGeneration, documentEpoch) = try #require( + reloadPurposes[reloadPendingKey] + ) else { + Issue.record("Expected a direct reply purpose.") + return + } + #expect(bindingGeneration == (try await transport.pageGeneration())) + #expect(documentEpoch == nil) + await receiveTargetReply( + transport, + targetID: reload.targetIdentifier, + messageID: try messageID(reload.message), + result: "{}" ) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-replacement")) + try await directTask.value - let clock = ContinuousClock() - let deadline = clock.now + .seconds(2) - while clock.now < deadline { - if await recorder.snapshot().count >= 2 { - break + let bodyGate = CloseConnectionGate() + let scopeTask = Task { + try await proxy.page.network.withEvents { _ in + await bodyGate.waitUntilReleased() } - await Task.yield() } + let generation = try await transport.pageGeneration() + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + let enableOperationID = try await requireCapabilityReplyPurpose( + in: transport, + message: enable, + expectedGeneration: generation + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + #expect(await transport.pendingReplyPurposes().isEmpty) - let events = await recorder.snapshot() - guard events.count >= 2 else { - Issue.record("Expected the page destruction and the replacement creation to be delivered.") - return - } - guard case .targetDestroyed(.currentPage) = events[0] else { - Issue.record("Expected the page destruction on the current-page route.") - return - } - if case .targetDestroyed = events[1] { - Issue.record("Frame target destruction was misdelivered to the current-page route.") - } + await bodyGate.release() + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + let disableOperationID = try await requireCapabilityReplyPurpose( + in: transport, + message: disable, + expectedGeneration: generation + ) + #expect(disableOperationID > enableOperationID) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + try await scopeTask.value + #expect(await transport.pendingReplyPurposes().isEmpty) } -private actor LifecycleEventRecorder { - private var events: [WebInspectorTargetLifecycleEvent] = [] +@Test +func structuredCSSScopeKeepsPageCapabilityAliveUntilCSSRelease() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyGate = CloseConnectionGate() - func record(_ event: WebInspectorTargetLifecycleEvent) { - events.append(event) + let scopeTask = Task { + try await proxy.page.css.withEvents { _ in + await bodyGate.waitUntilReleased() + } } - func snapshot() -> [WebInspectorTargetLifecycleEvent] { - events - } + let pageEnable = try await waitForTargetMessage(backend, method: "Page.enable") + await receiveTargetReply( + transport, + targetID: pageEnable.targetIdentifier, + messageID: try messageID(pageEnable.message), + result: "{}" + ) + let cssEnable = try await waitForTargetMessage(backend, method: "CSS.enable") + await receiveTargetReply( + transport, + targetID: cssEnable.targetIdentifier, + messageID: try messageID(cssEnable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + + await bodyGate.release() + let cssDisable = try await waitForTargetMessage(backend, method: "CSS.disable") + await receiveTargetReply( + transport, + targetID: cssDisable.targetIdentifier, + messageID: try messageID(cssDisable.message), + result: "{}" + ) + let pageDisable = try await waitForTargetMessage(backend, method: "Page.disable") + await receiveTargetReply( + transport, + targetID: pageDisable.targetIdentifier, + messageID: try messageID(pageDisable.message), + result: "{}" + ) + + try await scopeTask.value + #expect(await transport.pendingReplyPurposes().isEmpty) } @Test -func transportBackendEventSubscriptionWaitCompletesOnCancellation() async throws { +func structuredFrameCSSScopeDoesNotRequirePageCapability() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) - await installPageTarget(in: transport, targetID: ProtocolTarget.ID("page-main")) + await installPageTarget(in: transport) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false,"domains":["CSS"]}}}"# + ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() + let frame = WebInspectorTarget( + id: WebInspectorTarget.ID("frame-target"), + kind: .frame, + frameID: FrameID("child-frame"), + isProvisional: false, + proxy: proxy, + route: RoutingTargetID("frame-target") + ) + let bodyGate = CloseConnectionGate() - let waitTask = Task { - await target.proxy.waitForEventSubscription( - targetID: target.id, - route: target.route, - domain: .network - ) - return Task.isCancelled + let scopeTask = Task { + try await frame.css.withEvents { _ in + await bodyGate.waitUntilReleased() + } } - for _ in 0..<10 { - await Task.yield() + + let cssEnable = try await waitForTargetMessage(backend, method: "CSS.enable") + #expect(cssEnable.targetIdentifier == ProtocolTarget.ID("frame-target")) + let enableMethods = try await backend.sentTargetMessages().map { + try messageMethod($0.message) } - waitTask.cancel() + #expect(enableMethods == ["CSS.enable"]) + await receiveTargetReply( + transport, + targetID: cssEnable.targetIdentifier, + messageID: try messageID(cssEnable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() - let wasCancelled = try await value(of: waitTask, timeout: .seconds(2)) - #expect(wasCancelled) + await bodyGate.release() + let cssDisable = try await waitForTargetMessage(backend, method: "CSS.disable") + await receiveTargetReply( + transport, + targetID: cssDisable.targetIdentifier, + messageID: try messageID(cssDisable.message), + result: "{}" + ) + try await scopeTask.value + let completedMethods = try await backend.sentTargetMessages().map { + try messageMethod($0.message) + } + #expect(completedMethods == ["CSS.enable", "CSS.disable"]) } @Test -func transportBackendDecodesMemoryCacheResourceType() async throws { +func structuredNetworkScopeBuffersReplayBeforeEnableReplyAndBalancesDisable() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let scopeTask = Task { + try await proxy.page.network.withEvents(buffering: .bounded(1)) { events in + var iterator = events.makeAsyncIterator() + let reset = try await iterator.next() + let event = try await iterator.next() + return (reset, event) + } } - await waitForEventSubscription(target, domain: .network) + let enable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.requestServedFromMemoryCache", - params: #"{"requestId":"cached-1","timestamp":3,"resource":{"url":"https://example.test/app.css","type":"Stylesheet","bodySize":1234,"response":{"url":"https://example.test/app.css","status":200,"mimeType":"text/css","headers":{}}}}"# + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"enable-replay","timestamp":1}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - let event = try #require(try await value(of: eventTask)) - guard case let .requestServedFromMemoryCache(id, response, resourceType, timestamp) = event else { - Issue.record("Expected Network.requestServedFromMemoryCache event.") + let (reset, event) = try await throwingValue(of: scopeTask) + let generation: WebInspectorPage.Generation + guard case let .reset(value)? = reset else { + Issue.record("Expected an initial generation reset.") return } - #expect(id == Network.Request.ID("cached-1")) - #expect(response.url == "https://example.test/app.css") - #expect(response.bodySize == 1234) - #expect(resourceType == .stylesheet) - #expect(timestamp == 3) + generation = value + guard case let .event(eventGeneration, .loadingFinished(id, _, _, _))? = event else { + Issue.record("Expected enable-time Network replay.") + return + } + #expect(eventGeneration == generation) + #expect(id == Network.Request.ID("enable-replay")) } @Test -func transportBackendKeepsBackendResourceIdentifierOnScopedFrameRequests() async throws { +func structuredNetworkScopePreservesFrameScopedIdentifiers() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) @@ -1606,35 +1503,46 @@ func transportBackendKeepsBackendResourceIdentifierOnScopedFrameRequests() async #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let scopeTask = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() + } } - await waitForEventSubscription(target, domain: .network) + let enable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetEvent( transport, targetID: ProtocolTarget.ID("frame-target"), method: "Network.requestWillBeSent", - params: #"{"requestId":"frame-cached","request":{"url":"https://frame.example.test/cached","method":"GET"},"timestamp":1,"type":"Image","backendResourceIdentifier":{"sourceProcessID":"9","resourceID":"42"}}"# + params: #"{"requestId":"frame-request","request":{"url":"https://frame.example.test/","method":"GET"},"timestamp":1}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - let event = try #require(try await value(of: eventTask)) - guard case let .requestWillBeSent(id, request, _, _, _) = event else { - Issue.record("Expected projected frame Network.requestWillBeSent.") + guard case let .event(_, .requestWillBeSent(id, request, _, _, _))? = try await throwingValue(of: scopeTask) else { + Issue.record("Expected a projected frame Network event.") return } #expect(id.targetScopeRawValue == "frame-target") - #expect(request.backendResourceIdentifier == Network.BackendResourceID( - sourceProcessID: "9", - resourceID: "42" - )) + #expect(request.id.targetScopeRawValue == "frame-target") } @Test -func transportBackendRoutesRequestNodeThroughPageDOMAgentAndScopesResult() async throws { +func structuredRuntimeScopePreservesFrameScopedIdentifiers() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) @@ -1642,509 +1550,1699 @@ func transportBackendRoutesRequestNodeThroughPageDOMAgentAndScopesResult() async #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let requestNodeTask = Task { - try await target.dom.requestNode( - forRemoteObject: Runtime.RemoteObject.ID("frame-object", scopedToTargetRawValue: "frame-target") - ) + let scopeTask = Task { + try await proxy.page.runtime.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() + } } - let requestNode = try await waitForTargetMessage(backend, method: "DOM.requestNode") - #expect(requestNode.targetIdentifier == ProtocolTarget.ID("page-main")) - #expect(try messageParameters(requestNode.message)["objectId"] as? String == "frame-object") - await receiveTargetReply( - transport, - targetID: requestNode.targetIdentifier, - messageID: try messageID(requestNode.message), - result: #"{"nodeId":"frame-node"}"# + + let enable = try await waitForTargetMessage(backend, method: "Runtime.enable") + await receiveTargetEvent( + transport, + targetID: ProtocolTarget.ID("frame-target"), + method: "Runtime.executionContextCreated", + params: #"{"context":{"id":7,"name":"Frame","frameId":"child-frame","type":"normal"}}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Runtime.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + + guard case let .event(_, .executionContextCreated(context))? = try await throwingValue(of: scopeTask) else { + Issue.record("Expected a projected frame Runtime event.") + return + } + #expect(context.id.targetScopeRawValue == "frame-target") +} + +@Test +func structuredConsoleScopePreservesFrameScopedIdentifiers() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# + ) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + try await proxy.page.console.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() + } + } + + let enable = try await waitForTargetMessage(backend, method: "Console.enable") + await receiveTargetEvent( + transport, + targetID: ProtocolTarget.ID("frame-target"), + method: "Console.messageAdded", + params: #"{"message":{"source":"javascript","level":"log","text":"frame","parameters":[{"objectId":"frame-object","type":"object"}],"networkRequestId":"frame-request"}}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Console.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + + guard case let .event(_, .messageAdded(message))? = try await throwingValue(of: scopeTask) else { + Issue.record("Expected a projected frame Console event.") + return + } + #expect(message.parameters.first?.id?.targetScopeRawValue == "frame-target") + #expect(message.networkRequestID?.targetScopeRawValue == "frame-target") +} + +@Test +func structuredNetworkScopesShareOneLeaseAndLateScopeIsFutureOnly() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let firstGate = CloseConnectionGate() + let secondGate = CloseConnectionGate() + + let firstScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + let reset = try await iterator.next() + await firstGate.waitUntilReleased() + return reset + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"first-only","timestamp":1}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await firstGate.waitUntilStarted() + + let secondScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + let reset = try await iterator.next() + await secondGate.waitUntilReleased() + let event = try await iterator.next() + return (reset, event) + } + } + + await secondGate.waitUntilStarted() + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"future","timestamp":2}"# + ) + await secondGate.release() + + let (secondReset, secondEvent) = try await throwingValue(of: secondScope) + guard case .reset? = secondReset else { + Issue.record("Expected the late scope's generation reset.") + return + } + guard case let .event(_, .loadingFinished(id, _, _, _))? = secondEvent else { + Issue.record("Expected the late scope's future event.") + return + } + #expect(id == Network.Request.ID("future")) + + let sentMessages = await backend.sentTargetMessages() + let enableCount = try sentMessages.filter { + try messageMethod($0.message) == "Network.enable" + }.count + #expect(enableCount == 1) + let disableCountBeforeFinalRelease = try sentMessages.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCountBeforeFinalRelease == 0) + + await firstGate.release() + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + _ = try await throwingValue(of: firstScope) +} + +@Test +func structuredNetworkScopeCancellationDuringEnableWaitsAndBalancesLease() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyProbe = CompletionProbe() + let completionProbe = CompletionProbe() + + let scopeTask = Task { + let outcome: StructuredScopeOutcome + do { + try await proxy.page.network.withEvents { _ in + await bodyProbe.finish() + } + outcome = .succeeded + } catch is CancellationError { + outcome = .cancelled + } catch { + outcome = .other(String(describing: error)) + } + await completionProbe.finish() + return outcome + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + scopeTask.cancel() + await transport.waitForEventScopeCountForTesting(0) + #expect(await completionProbe.isFinished() == false) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(await completionProbe.isFinished() == false) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + + #expect(try await value(of: scopeTask) == .cancelled) + #expect(await completionProbe.isFinished()) + #expect(await bodyProbe.isFinished() == false) +} + +@Test +func structuredNetworkScopeCancelledActivationIsNotTreatedAsPreviouslyActive() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let cancellationGate = CloseConnectionGate() + await transport.replaceEventScopeActivationCancellationActionForTesting { + await cancellationGate.waitUntilReleased() + } + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch is CancellationError { + return .cancelled + } catch { + return .other(String(describing: error)) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + scopeTask.cancel() + await cancellationGate.waitUntilStarted() + + await receiveTargetError( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + message: "enable rejected" + ) + #expect(await transport.terminalCause == nil) + + await cancellationGate.release() + #expect(try await value(of: scopeTask) == .cancelled) + await transport.waitForEventScopeCountForTesting(0) + #expect(await transport.terminalCause == nil) + + let messages = await backend.sentTargetMessages() + #expect(try messages.filter { try messageMethod($0.message) == "Network.enable" }.count == 1) + #expect(try messages.filter { try messageMethod($0.message) == "Network.disable" }.isEmpty) +} + +@Test +func structuredNetworkScopeCancellationDuringSharedEnableDoesNotCancelPeerLease() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let cancelledBodyProbe = CompletionProbe() + let peerGate = CloseConnectionGate() + + let cancelledScope = Task { + do { + try await proxy.page.network.withEvents { _ in + await cancelledBodyProbe.finish() + } + return StructuredScopeOutcome.succeeded + } catch is CancellationError { + return .cancelled + } catch { + return .other(String(describing: error)) + } + } + let peerScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await peerGate.waitUntilReleased() + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await transport.waitForEventScopeCountForTesting(2) + cancelledScope.cancel() + await transport.waitForEventScopeCountForTesting(1) + + #expect(try await value(of: cancelledScope) == .cancelled) + #expect(await cancelledBodyProbe.isFinished() == false) + var sentMessages = await backend.sentTargetMessages() + #expect(try sentMessages.filter { try messageMethod($0.message) == "Network.enable" }.count == 1) + #expect(try sentMessages.filter { try messageMethod($0.message) == "Network.disable" }.isEmpty) + + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await peerGate.waitUntilStarted() + + sentMessages = await backend.sentTargetMessages() + #expect(try sentMessages.filter { try messageMethod($0.message) == "Network.enable" }.count == 1) + #expect(try sentMessages.filter { try messageMethod($0.message) == "Network.disable" }.isEmpty) + + await peerGate.release() + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + _ = try await throwingValue(of: peerScope) +} + +@Test +func structuredNetworkScopeCancellationDuringEnablePreservesCleanupFailure() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch let error as WebInspectorScopeError { + let operationIsCancellation = error.operationError is CancellationError + let cleanupIsExpected: Bool + if let cleanupError = error.cleanupError as? WebInspectorProxyError, + case WebInspectorProxyError.commandRejected( + method: "Network.disable", + message: "disable rejected" + ) = cleanupError { + cleanupIsExpected = true + } else { + cleanupIsExpected = false + } + return operationIsCancellation && cleanupIsExpected + ? .combinedFailure + : .other(String(describing: error)) + } catch { + return .other(String(describing: error)) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + scopeTask.cancel() + await transport.waitForEventScopeCountForTesting(0) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetError( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + message: "disable rejected" + ) + + #expect(try await value(of: scopeTask) == .combinedFailure) +} + +@Test +func structuredNetworkScopeEnableTimeoutTerminatesConnectionWithoutReenabling() async throws { + let backend = FakeTransportBackend() + let timeout = ManualResponseTimeout() + let transport = TransportSession( + backend: backend, + responseTimeout: .seconds(30), + timeoutSleep: { duration in + try await timeout.sleep(for: duration) + } + ) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.timeout(domain: "Network", method: "enable") { + return .timeout + } catch { + return .other(String(describing: error)) + } + } + + _ = try await waitForTargetMessage(backend, method: "Network.enable") + await timeout.waitUntilSuspended() + await timeout.fireNext() + + #expect(await scopeTask.value == .timeout) + do { + try await proxy.waitUntilClosed() + Issue.record("Expected uncertain enable timeout to terminate the connection.") + } catch WebInspectorProxyError.disconnected { + // A timed-out enable may have succeeded on the wire, so the connection + // is terminal after the initiating scope receives its timeout. + } catch { + Issue.record("Expected a disconnected terminal result, got \(error).") + } + + let secondScope = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.transportFailure { + return .transportFailure + } catch { + return .other(String(describing: error)) + } + } + #expect(await secondScope.value == .transportFailure) + + let messages = await backend.sentTargetMessages() + let enableCount = try messages.filter { + try messageMethod($0.message) == "Network.enable" + }.count + #expect(enableCount == 1) +} + +@Test +func structuredNetworkScopeDisableTimeoutTerminatesConnectionWithoutReusingWireState() async throws { + let backend = FakeTransportBackend() + let timeout = ManualResponseTimeout() + let transport = TransportSession( + backend: backend, + responseTimeout: .seconds(30), + timeoutSleep: { duration in + try await timeout.sleep(for: duration) + } + ) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.timeout(domain: "Network", method: "disable") { + return .timeout + } catch { + return .other(String(describing: error)) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + _ = try await waitForTargetMessage(backend, method: "Network.disable") + await timeout.waitUntilSuspended() + await timeout.fireNext() + + #expect(try await value(of: scopeTask) == .timeout) + do { + try await proxy.waitUntilClosed() + Issue.record("Expected uncertain disable timeout to terminate the connection.") + } catch WebInspectorProxyError.disconnected { + // A timed-out disable may have succeeded on the wire, so no later + // scope can safely reuse the connection's cached capability state. + } catch { + Issue.record("Expected a disconnected terminal result, got \(error).") + } + + let secondScope = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.transportFailure { + return .transportFailure + } catch { + return .other(String(describing: error)) + } + } + #expect(try await value(of: secondScope) == .transportFailure) + + let messages = await backend.sentTargetMessages() + #expect(try messages.filter { try messageMethod($0.message) == "Network.enable" }.count == 1) + #expect(try messages.filter { try messageMethod($0.message) == "Network.disable" }.count == 1) +} + +@Test +func structuredNetworkScopePreservesBodyAndCleanupFailures() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ -> Void in + throw StructuredScopeBodyFailure() + } + return StructuredScopeOutcome.succeeded + } catch let error as WebInspectorScopeError { + let operationIsExpected = error.operationError is StructuredScopeBodyFailure + let cleanupIsExpected: Bool + if let cleanupError = error.cleanupError as? WebInspectorProxyError, + case WebInspectorProxyError.commandRejected( + method: "Network.disable", + message: "disable rejected" + ) = cleanupError { + cleanupIsExpected = true + } else { + cleanupIsExpected = false + } + return operationIsExpected && cleanupIsExpected ? .combinedFailure : .other(String(describing: error)) + } catch { + return .other(String(describing: error)) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetError( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + message: "disable rejected" + ) + + #expect(await scopeTask.value == StructuredScopeOutcome.combinedFailure) +} + +@Test +func structuredNetworkScopeThrowsCleanupFailureAfterSuccessfulBody() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ in } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.commandRejected( + method: "Network.disable", + message: "disable rejected" + ) { + return .cleanupFailure + } catch { + return .other(String(describing: error)) + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetError( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + message: "disable rejected" + ) + + #expect(await scopeTask.value == .cleanupFailure) +} + +@Test +func structuredNetworkScopeRethrowsBodyFailureAfterSuccessfulCleanup() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { _ -> Void in + throw StructuredScopeBodyFailure() + } + return StructuredScopeOutcome.succeeded + } catch is StructuredScopeBodyFailure { + return .bodyFailure + } catch { + return .other(String(describing: error)) + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + + #expect(await scopeTask.value == .bodyFailure) +} + +@Test +func structuredNetworkScopeEndsNormallyOnExplicitClose() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let closeGate = CloseConnectionGate() + let proxy = try await WebInspectorProxy(transport: transport, closeConnection: { + await closeGate.waitUntilReleased() + }) + let bodyGate = CloseConnectionGate() + let bodyCompletion = CompletionProbe() + + let scopeTask = Task { + do { + return try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + guard try await iterator.next() == nil else { + return StructuredScopeOutcome.other( + "Expected explicit close to finish the stream normally." + ) + } + await bodyCompletion.finish() + return StructuredScopeOutcome.succeeded + } + } catch { + return StructuredScopeOutcome.other(String(describing: error)) + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + await bodyGate.release() + + let closeTask = Task { + await proxy.close() + } + await closeGate.waitUntilStarted() + + #expect(await transport.activeEventScopeSubscriberCountForTesting() == 1) + #expect(await bodyCompletion.isFinished() == false) + + await closeGate.release() + await closeTask.value + + #expect(await scopeTask.value == .succeeded) + #expect(await bodyCompletion.isFinished()) +} + +@Test +func structuredNetworkScopeThrowsTransportFailureOnNativeFatal() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyGate = CloseConnectionGate() + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + while try await iterator.next() != nil {} + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.transportFailure("native fatal") { + return .transportFailure + } catch { + return .other(String(describing: error)) + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + await bodyGate.release() + + let fatalHandoff = try #require(transport.failFromNativeCallback("native fatal")) + await fatalHandoff.value + + #expect(await scopeTask.value == .transportFailure) +} + +@Test +func structuredNetworkScopeMalformedKnownEventTerminatesWithProtocolViolation() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyGate = CloseConnectionGate() + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + while try await iterator.next() != nil {} + } + return StructuredScopeOutcome.succeeded + } catch is WebInspectorScopeError { + return .other("Connection termination must not also report a cleanup failure.") + } catch WebInspectorProxyError.protocolViolation { + return .protocolViolation + } catch { + return .other(String(describing: error)) + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + await bodyGate.release() + + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"timestamp":1}"# ) - let nodeID = try await requestNodeTask.value - #expect(nodeID == DOM.Node.ID("frame-node", scopedToTargetRawValue: "frame-target")) - #expect(nodeID.targetScopeRawValue == "frame-target") + + #expect(await scopeTask.value == .protocolViolation) } @Test -func transportBackendForwardsBackendResourceIdentifierToResponseBodyCommand() async throws { +func malformedTargetCreatedTerminatesBeforeRegistryCanDrift() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"type":"page","isProvisional":false}}}"# + ) + + do { + try await transport.waitUntilClosed() + Issue.record("Expected malformed Target.targetCreated to terminate the connection.") + } catch WebInspectorProxyError.protocolViolation { + // Registry-mutating known events are fail-fast at ingress. + } catch { + Issue.record("Expected protocol violation, got \(error).") + } + #expect(await transport.snapshot().targetsByID.isEmpty) +} + +@Test +func targetCommitWithoutRequiredOldTargetTerminatesWithProtocolViolation() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"newTargetId":"page-next"}}"# + ) + + do { + try await transport.waitUntilClosed() + Issue.record("Expected missing oldTargetId to terminate the connection.") + } catch WebInspectorProxyError.protocolViolation { + // iOS 18.4+ makes both commit identifiers required. + } catch { + Issue.record("Expected protocol violation, got \(error).") } +} + +@Test +func structuredNetworkScopeKeepsUnknownMethodsAsRawEvents() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) - await waitForEventSubscription(target, domain: .network) + let scopeTask = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + return try await iterator.next() + } + } + let enable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.requestWillBeSent", - params: #"{"requestId":"cached-request","request":{"url":"https://example.test/cached","method":"GET"},"timestamp":1,"type":"Image","backendResourceIdentifier":{"sourceProcessID":"77","resourceID":"1234"}}"# + targetID: enable.targetIdentifier, + method: "Network.futureEvent", + params: #"{"value":42}"# + ) + await receiveTargetReply( + transport, + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - let event = try #require(try await value(of: eventTask)) - guard case let .requestWillBeSent(id, request, _, _, _) = event else { - Issue.record("Expected Network.requestWillBeSent for the current page.") + guard case let .event(_, .unknown(rawEvent))? = try await throwingValue(of: scopeTask) else { + Issue.record("Expected an unknown Network event.") return } - #expect(request.backendResourceIdentifier == Network.BackendResourceID( - sourceProcessID: "77", - resourceID: "1234" - )) + #expect(rawEvent.domain == "Network") + #expect(rawEvent.method == "futureEvent") +} - let bodyTask = Task { - try await target.network.responseBody( - for: id, - backendResourceIdentifier: request.backendResourceIdentifier - ) +@Test +func structuredNetworkScopeMalformedRootEnvelopeTerminatesWithProtocolViolation() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyGate = CloseConnectionGate() + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + while try await iterator.next() != nil {} + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.protocolViolation { + return .protocolViolation + } catch { + return .other(String(describing: error)) + } } - let bodyCommand = try await waitForTargetMessage(backend, method: "Network.getResponseBody") - let parameters = try messageParameters(bodyCommand.message) - #expect(parameters["requestId"] as? String == "cached-request") - let identifier = try #require(parameters["backendResourceIdentifier"] as? [String: Any]) - #expect(identifier["sourceProcessID"] as? String == "77") - #expect(identifier["resourceID"] as? String == "1234") + let enable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetReply( transport, - targetID: bodyCommand.targetIdentifier, - messageID: try messageID(bodyCommand.message), - result: #"{"body":"cached body","base64Encoded":false}"# + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" ) - let body = try await bodyTask.value - #expect(body.data == "cached body") + await bodyGate.waitUntilStarted() + await bodyGate.release() + + await transport.receiveRootMessage("{") + + #expect(await scopeTask.value == .protocolViolation) } @Test -func transportBackendDeliversParentlessFrameNetworkEventsToCurrentPageRoute() async throws { +func structuredNetworkScopePreservesPendingEventBeforeRetargetResetAndNewReplay() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-page-target","type":"page","frameId":"child-frame","isProvisional":false}}}"# - ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.route == .currentPage) - - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let gate = CloseConnectionGate() + + let scopeTask = Task { + try await proxy.page.network.withEvents(buffering: .bounded(2)) { events in + var iterator = events.makeAsyncIterator() + let initialReset = try await iterator.next() + await gate.waitUntilReleased() + let precedingEvent = try await iterator.next() + let replacementReset = try await iterator.next() + let replay = try await iterator.next() + return (initialReset, precedingEvent, replacementReset, replay) + } } - await waitForEventSubscription(target, domain: .network) + let initialEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: initialEnable.targetIdentifier, + messageID: try messageID(initialEnable.message), + result: "{}" + ) + await gate.waitUntilStarted() + await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("frame-page-target"), - method: "Network.requestWillBeSent", - params: #"{"requestId":"frame-request","frameId":"child-frame","request":{"url":"https://frame.example.test/","method":"GET"},"timestamp":7.5,"type":"Document"}"# + targetID: initialEnable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"preceding-event","timestamp":2}"# + ) + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"# + ) + + let replacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + #expect(replacementEnable.targetIdentifier == ProtocolTarget.ID("page-next")) + await receiveTargetEvent( + transport, + targetID: replacementEnable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"replacement-replay","timestamp":3}"# + ) + await receiveTargetReply( + transport, + targetID: replacementEnable.targetIdentifier, + messageID: try messageID(replacementEnable.message), + result: "{}" + ) + await gate.release() + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-next")) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - let event = try #require(try await value(of: eventTask)) - guard case let .requestWillBeSent(id, request, resourceType, _, timestamp) = event else { - Issue.record("Expected current-page route to receive parentless frame Network.requestWillBeSent.") + let (initial, preceding, replacement, replay) = try await throwingValue(of: scopeTask) + guard case let .reset(initialGeneration)? = initial, + case let .reset(replacementGeneration)? = replacement else { + Issue.record("Expected initial and replacement reset markers.") + return + } + #expect(initialGeneration != replacementGeneration) + guard case let .event(precedingGeneration, .loadingFinished(precedingID, _, _, _))? = preceding else { + Issue.record("Expected the pending old-generation event before the replacement reset.") + return + } + #expect(precedingGeneration == initialGeneration) + #expect(precedingID == Network.Request.ID("preceding-event")) + guard case let .event(eventGeneration, .loadingFinished(id, _, _, _))? = replay else { + Issue.record("Expected replacement enable replay.") return } - #expect(id == Network.Request.ID("frame-request", scopedToTargetRawValue: "frame-page-target")) - #expect(request.id == id) - #expect(request.url == "https://frame.example.test/") - #expect(request.method == "GET") - #expect(resourceType == .document) - #expect(timestamp == 7.5) + #expect(eventGeneration == replacementGeneration) + #expect(id == Network.Request.ID("replacement-replay")) } @Test -func transportBackendDoesNotDeliverUnrelatedFrameNetworkEventsToCurrentPageRoute() async throws { +func structuredNetworkScopeCoalescesConsecutiveDirectReplacementResetsWithoutConsumingCapacity() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"other-frame-target","type":"frame","frameId":"other-child-frame","parentFrameId":"other-main-frame","isProvisional":false}}}"# - ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.route == .currentPage) - - let eventProbe = CompletionProbe() - let eventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - if await iterator.next() != nil { - await eventProbe.finish() + let gate = CloseConnectionGate() + + let scopeTask = Task { + try await proxy.page.network.withEvents(buffering: .bounded(1)) { events in + var iterator = events.makeAsyncIterator() + let initialReset = try await iterator.next() + await gate.waitUntilReleased() + let replacementReset = try await iterator.next() + let replay = try await iterator.next() + return (initialReset, replacementReset, replay) } } - defer { - eventTask.cancel() - } - await waitForEventSubscription(target, domain: .network) + let initialEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: initialEnable.targetIdentifier, + messageID: try messageID(initialEnable.message), + result: "{}" + ) + await gate.waitUntilStarted() + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next-1","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next-1"}}"# + ) + let firstReplacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + #expect(firstReplacementEnable.targetIdentifier == ProtocolTarget.ID("page-next-1")) + let intermediateGeneration = try await proxy.page.generation + await receiveTargetReply( + transport, + targetID: firstReplacementEnable.targetIdentifier, + messageID: try messageID(firstReplacementEnable.message), + result: "{}" + ) + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next-2","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-next-1","newTargetId":"page-next-2"}}"# + ) + let latestGeneration = try await proxy.page.generation + #expect(latestGeneration != intermediateGeneration) + + let secondReplacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 2 + ) + #expect(secondReplacementEnable.targetIdentifier == ProtocolTarget.ID("page-next-2")) await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("other-frame-target"), - method: "Network.requestWillBeSent", - params: #"{"requestId":"other-frame-request","frameId":"other-child-frame","request":{"url":"https://other-frame.example.test/","method":"GET"},"timestamp":8.5,"type":"Document"}"# + targetID: secondReplacementEnable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"latest-replay","timestamp":4}"# + ) + await receiveTargetReply( + transport, + targetID: secondReplacementEnable.targetIdentifier, + messageID: try messageID(secondReplacementEnable.message), + result: "{}" + ) + await gate.release() + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-next-2")) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - try await Task.sleep(for: .milliseconds(100)) - #expect(await eventProbe.isFinished() == false) + let (initial, replacement, replay) = try await throwingValue(of: scopeTask) + guard case let .reset(initialGeneration)? = initial, + case let .reset(replacementGeneration)? = replacement else { + Issue.record("Expected one initial reset and one coalesced replacement reset.") + return + } + #expect(initialGeneration != replacementGeneration) + #expect(replacementGeneration == latestGeneration) + guard case let .event(eventGeneration, .loadingFinished(id, _, _, _))? = replay else { + Issue.record("Expected the latest generation replay immediately after the coalesced reset.") + return + } + #expect(eventGeneration == latestGeneration) + #expect(id == Network.Request.ID("latest-replay")) } @Test -func transportBackendDoesNotDeliverFrameDocumentUpdatedToCurrentPageDOMRoute() async throws { +func structuredNetworkScopeSurvivesReplacementDestroyedDuringReenable() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# - ) let proxy = try await WebInspectorProxy(transport: transport) - let target = try await proxy.waitForCurrentPage() - #expect(target.route == .currentPage) - - let eventProbe = CompletionProbe() - let eventTask = Task { - var iterator = target.dom.events.makeAsyncIterator() - if await iterator.next() != nil { - await eventProbe.finish() + let gate = CloseConnectionGate() + + let scopeTask = Task { + try await proxy.page.network.withEvents(buffering: .bounded(1)) { events in + var iterator = events.makeAsyncIterator() + let initialReset = try await iterator.next() + await gate.waitUntilReleased() + let replacementReset = try await iterator.next() + let replay = try await iterator.next() + return (initialReset, replacementReset, replay) } } - defer { - eventTask.cancel() - } - await waitForEventSubscription(target, domain: .dom) + let initialEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: initialEnable.targetIdentifier, + messageID: try messageID(initialEnable.message), + result: "{}" + ) + await gate.waitUntilStarted() + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-replacement-a","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-replacement-a"}}"# + ) + let abandonedEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + #expect(abandonedEnable.targetIdentifier == ProtocolTarget.ID("page-replacement-a")) + + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-replacement-a"}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-replacement-b","type":"page","frameId":"main-frame","isProvisional":false}}}"# + ) + let latestGeneration = try await proxy.page.generation + + let replacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 2 + ) + #expect(replacementEnable.targetIdentifier == ProtocolTarget.ID("page-replacement-b")) await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "DOM.documentUpdated", - params: "{}" + targetID: replacementEnable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"surviving-replay","timestamp":5}"# + ) + await receiveTargetReply( + transport, + targetID: replacementEnable.targetIdentifier, + messageID: try messageID(replacementEnable.message), + result: "{}" + ) + await gate.release() + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-replacement-b")) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - try await Task.sleep(for: .milliseconds(100)) - #expect(await eventProbe.isFinished() == false) + let (initial, replacement, replay) = try await throwingValue(of: scopeTask) + guard case let .reset(initialGeneration)? = initial, + case let .reset(replacementGeneration)? = replacement else { + Issue.record("Expected initial and latest replacement resets.") + return + } + #expect(initialGeneration != replacementGeneration) + #expect(replacementGeneration == latestGeneration) + guard case let .event(eventGeneration, .loadingFinished(id, _, _, _))? = replay else { + Issue.record("Expected replay from the surviving replacement target.") + return + } + #expect(eventGeneration == latestGeneration) + #expect(id == Network.Request.ID("surviving-replay")) + #expect(await transport.terminalCause == nil) } @Test -func transportBackendDecodesWebSocketHandshakeEventsForTargetRoute() async throws { +func structuredNetworkScopeCoalescesDestroyCreateIntoOneReplacementReset() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - - let requestEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() + let proxy = try await WebInspectorProxy(transport: transport) + let gate = CloseConnectionGate() + + let scopeTask = Task { + try await proxy.page.network.withEvents(buffering: .bounded(1)) { events in + var iterator = events.makeAsyncIterator() + let initialReset = try await iterator.next() + await gate.waitUntilReleased() + let replacementReset = try await iterator.next() + let replay = try await iterator.next() + return (initialReset, replacementReset, replay) + } } - await waitForEventSubscription(target, domain: .network) + let initialEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: initialEnable.targetIdentifier, + messageID: try messageID(initialEnable.message), + result: "{}" + ) + await gate.waitUntilStarted() + + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-main"}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":false}}}"# + ) + + let replacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + #expect(replacementEnable.targetIdentifier == ProtocolTarget.ID("page-next")) await receiveTargetEvent( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.webSocketWillSendHandshakeRequest", - params: #"{"requestId":"ws-1","timestamp":1.25,"request":{"headers":{"Upgrade":"websocket","Sec-WebSocket-Key":"abc"}}}"# + targetID: replacementEnable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"destroy-create-replay","timestamp":4}"# + ) + await receiveTargetReply( + transport, + targetID: replacementEnable.targetIdentifier, + messageID: try messageID(replacementEnable.message), + result: "{}" + ) + await gate.release() + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-next")) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" ) - let requestEvent = try #require(try await value(of: requestEventTask)) - guard case let .webSocket(.handshakeRequest(id, request, timestamp)) = requestEvent else { - Issue.record("Expected Network.webSocketWillSendHandshakeRequest.") + let (initial, replacement, replay) = try await throwingValue(of: scopeTask) + guard case let .reset(initialGeneration)? = initial, + case let .reset(replacementGeneration)? = replacement else { + Issue.record("Expected exactly one reset for the replacement generation.") return } - #expect(id == Network.Request.ID("ws-1")) - #expect(request.id == Network.Request.ID("ws-1")) - #expect(request.method == "GET") - #expect(request.headers["Upgrade"] == "websocket") - #expect(request.headers["Sec-WebSocket-Key"] == "abc") - #expect(timestamp == 1.25) + #expect(initialGeneration != replacementGeneration) + guard case let .event(eventGeneration, .loadingFinished(id, _, _, _))? = replay else { + Issue.record("Expected replacement replay immediately after the reset.") + return + } + #expect(eventGeneration == replacementGeneration) + #expect(id == Network.Request.ID("destroy-create-replay")) +} - let responseEventTask = Task { - var iterator = target.network.events.makeAsyncIterator() - return await iterator.next() +@Test +func structuredNetworkScopeTreatsCurrentPageDestroyDuringDisableAsSuccessfulCleanup() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let bodyGate = CloseConnectionGate() + + let scopeTask = Task { + do { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + } + return StructuredScopeOutcome.succeeded + } catch { + return .other(String(describing: error)) + } } - await waitForEventSubscription(target, domain: .network) - await receiveTargetEvent( + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.webSocketHandshakeResponseReceived", - params: #"{"requestId":"ws-1","timestamp":2.5,"response":{"status":101,"statusText":"Switching Protocols","headers":{"Upgrade":"websocket"}}}"# + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" ) + await bodyGate.waitUntilStarted() + await bodyGate.release() - let responseEvent = try #require(try await value(of: responseEventTask)) - guard case let .webSocket(.handshakeResponse(responseID, response, responseTimestamp)) = responseEvent else { - Issue.record("Expected Network.webSocketHandshakeResponseReceived.") - return - } - #expect(responseID == Network.Request.ID("ws-1")) - #expect(response.status == 101) - #expect(response.statusText == "Switching Protocols") - #expect(response.headers["Upgrade"] == "websocket") - #expect(responseTimestamp == 2.5) + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-main")) + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"page-main"}}"# + ) + + #expect(await scopeTask.value == .succeeded) + #expect(await transport.terminalCause == nil) + let messages = await backend.sentTargetMessages() + let disableCount = try messages.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCount == 1) } @Test -func transportBackendFiltersEventsByRoute() async throws { +func structuredPhysicalTargetScopeFinishesWithoutDisableWhenTargetIsDestroyed() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) await transport.receiveRootMessage( - #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"frame-1","isProvisional":false}}}"# + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# ) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) - let page = pageTarget(proxy: proxy) + let proxy = try await WebInspectorProxy(transport: transport) let frame = WebInspectorTarget( id: WebInspectorTarget.ID("frame-target"), kind: .frame, - frameID: FrameID("frame-1"), + frameID: FrameID("child-frame"), isProvisional: false, proxy: proxy, route: RoutingTargetID("frame-target") ) + let bodyGate = CloseConnectionGate() - let pageEventTask = Task { - var iterator = page.network.events.makeAsyncIterator() - return await iterator.next() - } - let frameEventTask = Task { - var iterator = frame.network.events.makeAsyncIterator() - return await iterator.next() + let scopeTask = Task { + do { + try await frame.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await bodyGate.waitUntilReleased() + while try await iterator.next() != nil {} + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.pageUnavailable { + return .pageUnavailable + } catch { + return .other(String(describing: error)) + } } - await waitForEventSubscription(page, domain: .network) - await waitForEventSubscription(frame, domain: .network) - await receiveTargetEvent( + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + #expect(enable.targetIdentifier == ProtocolTarget.ID("frame-target")) + await receiveTargetReply( transport, - targetID: ProtocolTarget.ID("frame-target"), - method: "Network.loadingFinished", - params: #"{"requestId":"frame-request","timestamp":4,"sourceMapURL":"frame.js.map","metrics":{"protocol":"h2","remoteAddress":"203.0.113.10:443","responseBodyBytesReceived":128,"responseBodyDecodedSize":256}}"# + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" + ) + await bodyGate.waitUntilStarted() + await bodyGate.release() + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"frame-target"}}"# ) - let frameEvent = try #require(try await value(of: frameEventTask)) - guard case let .loadingFinished(id, timestamp, sourceMapURL, metrics) = frameEvent else { - Issue.record("Expected frame route to receive Network.loadingFinished.") - return - } - #expect(id == Network.Request.ID("frame-request")) - #expect(timestamp == 4) - #expect(sourceMapURL == "frame.js.map") - #expect(metrics?.networkProtocol == "h2") - #expect(metrics?.remoteAddress == "203.0.113.10:443") - #expect(metrics?.encodedDataLength == 128) - #expect(metrics?.decodedBodyLength == 256) - - pageEventTask.cancel() + #expect(await scopeTask.value == .pageUnavailable) + let messages = await backend.sentTargetMessages() + let disableCount = try messages.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCount == 0) + #expect(await transport.terminalCause == nil) } @Test -func transportBackendKeepsPeerSubscriberActiveWhenOneStreamTerminates() async throws { +func structuredPhysicalTargetScopeFailsAcquisitionWhenTargetDiesDuringEnable() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxyBackend = LiveWebInspectorProxyBackend(transport: transport) - let page = pageTarget(proxy: WebInspectorProxy(backend: proxyBackend)) - let probe = EventDeliveryProbe() - - let firstSubscriber = Task { - var iterator = page.network.events.makeAsyncIterator() - if await iterator.next() != nil { - await probe.recordFirst() - } - } - let secondSubscriber = Task { - var iterator = page.network.events.makeAsyncIterator() - while await iterator.next() != nil { - await probe.recordSecond() - } - } - - // Barrier on both subscribers being registered before the first event is - // published; a generous bound keeps subscriber-task startup contention - // (CI) from turning into an execution-time-allowance kill. - try await value( - of: Task { - await proxyBackend.waitForEventSubscriptions( - route: page.route, - targetID: page.id, - domain: .network, - minimumCount: 2 - ) - }, - timeout: .seconds(10) + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"frame-target","type":"frame","frameId":"child-frame","parentFrameId":"main-frame","isProvisional":false}}}"# ) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.loadingFinished", - params: #"{"requestId":"first","timestamp":1}"# + let proxy = try await WebInspectorProxy(transport: transport) + let frame = WebInspectorTarget( + id: WebInspectorTarget.ID("frame-target"), + kind: .frame, + frameID: FrameID("child-frame"), + isProvisional: false, + proxy: proxy, + route: RoutingTargetID("frame-target") ) + let bodyProbe = CompletionProbe() - try await value(of: Task { await probe.waitForFirstCount(1) }, timeout: transportCommandBackendWaitTimeout) - try await value(of: Task { await probe.waitForSecondCount(1) }, timeout: transportCommandBackendWaitTimeout) - try await value(of: firstSubscriber, timeout: transportCommandBackendWaitTimeout) - - try await value( - of: Task { await waitForEventSubscription(page, domain: .network) }, - timeout: transportCommandBackendWaitTimeout - ) + let scopeTask = Task { + do { + try await frame.network.withEvents { _ in + await bodyProbe.finish() + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.pageUnavailable { + return .pageUnavailable + } catch { + return .other(String(describing: error)) + } + } - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Network.loadingFinished", - params: #"{"requestId":"second","timestamp":2}"# + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + #expect(enable.targetIdentifier == ProtocolTarget.ID("frame-target")) + await transport.receiveRootMessage( + #"{"method":"Target.targetDestroyed","params":{"targetId":"frame-target"}}"# ) - try await value(of: Task { await probe.waitForSecondCount(2) }, timeout: transportCommandBackendWaitTimeout) - secondSubscriber.cancel() + #expect(await scopeTask.value == .pageUnavailable) + #expect(await bodyProbe.isFinished() == false) + await transport.waitForEventScopeCountForTesting(0) + let messages = await backend.sentTargetMessages() + let disableCount = try messages.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCount == 0) } @Test -func transportBackendRuntimeClearedUsesSemanticTargetID() async throws { +func structuredFixedTargetScopeFailsAtCommitWithoutRetargetingPendingEnable() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let proxy = WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport)) - let retargeted = WebInspectorTarget( - id: WebInspectorTarget.ID("semantic-page"), + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + let proxy = try await WebInspectorProxy(transport: transport) + let fixedPage = WebInspectorTarget( + id: WebInspectorTarget.ID("page-main"), kind: .page, frameID: FrameID("main-frame"), isProvisional: false, proxy: proxy, route: RoutingTargetID("page-main") ) + let bodyProbe = CompletionProbe() - let eventTask = Task { - var iterator = retargeted.runtime.events.makeAsyncIterator() - return await iterator.next() + let scopeTask = Task { + do { + try await fixedPage.network.withEvents { _ in + await bodyProbe.finish() + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.pageUnavailable { + return .pageUnavailable + } catch { + return .other(String(describing: error)) + } } - await waitForEventSubscription(retargeted, domain: .runtime) - await receiveTargetEvent( - transport, - targetID: ProtocolTarget.ID("page-main"), - method: "Runtime.executionContextsCleared", - params: "{}" + let enable = try await waitForTargetMessage(backend, method: "Network.enable") + #expect(enable.targetIdentifier == ProtocolTarget.ID("page-main")) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"# ) - let event = try #require(try await value(of: eventTask)) - guard case let .executionContextsCleared(target) = event else { - Issue.record("Expected Runtime.executionContextsCleared.") - return - } - #expect(target == WebInspectorTarget.ID("semantic-page")) + #expect(try await value(of: scopeTask) == .pageUnavailable) + #expect(await bodyProbe.isFinished() == false) + await transport.waitForEventScopeCountForTesting(0) + #expect(await transport.terminalCause == nil) + + let messages = await backend.sentTargetMessages() + #expect(messages.count == 1) + #expect(messages[0].targetIdentifier == ProtocolTarget.ID("page-main")) + #expect(try messageMethod(messages[0].message) == "Network.enable") } @Test -func transportCommandBackendDecodesRuntimeEvaluationResult() async throws { +func structuredNetworkScopeLateAcquireDuringReenableDoesNotDisableLiveLease() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - - let evaluateTask = Task { - try await target.runtime.evaluate("document.title", in: Runtime.ExecutionContext.ID("7")) + let proxy = try await WebInspectorProxy(transport: transport) + let firstGate = CloseConnectionGate() + let lateGate = CloseConnectionGate() + + let firstScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await firstGate.waitUntilReleased() + } } - let sent = try await waitForTargetMessage(backend, method: "Runtime.evaluate") - #expect(sent.targetIdentifier == ProtocolTarget.ID("page-main")) - #expect(try messageMethod(sent.message) == "Runtime.evaluate") - let parameters = try messageParameters(sent.message) - #expect(parameters["expression"] as? String == "document.title") - #expect((parameters["contextId"] as? NSNumber)?.intValue == 7) + let initialEnable = try await waitForTargetMessage(backend, method: "Network.enable") + await receiveTargetReply( + transport, + targetID: initialEnable.targetIdentifier, + messageID: try messageID(initialEnable.message), + result: "{}" + ) + await firstGate.waitUntilStarted() + + await transport.receiveRootMessage( + #"{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"page-next","type":"page","frameId":"main-frame","isProvisional":true}}}"# + ) + await transport.receiveRootMessage( + #"{"method":"Target.didCommitProvisionalTarget","params":{"oldTargetId":"page-main","newTargetId":"page-next"}}"# + ) + let replacementEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + + await firstGate.release() + await transport.waitForEventScopeCountForTesting(0) + + let lateScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await lateGate.waitUntilReleased() + } + } + await transport.waitForEventScopeCountForTesting(1) await receiveTargetReply( transport, - targetID: sent.targetIdentifier, - messageID: try messageID(sent.message), - result: #"{"result":{"type":"string","value":"Title","description":"Title"},"wasThrown":true,"savedResultIndex":3}"# + targetID: replacementEnable.targetIdentifier, + messageID: try messageID(replacementEnable.message), + result: "{}" ) + await lateGate.waitUntilStarted() + _ = try await throwingValue(of: firstScope) - let result = try await evaluateTask.value - #expect(result.object.kind == .string) - #expect(result.object.value == .string("Title")) - #expect(result.object.description == "Title") - #expect(result.wasThrown == true) - #expect(result.savedResultIndex == 3) + let messagesBeforeLateRelease = await backend.sentTargetMessages() + let disableCount = try messagesBeforeLateRelease.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCount == 0) + + await lateGate.release() + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + #expect(disable.targetIdentifier == ProtocolTarget.ID("page-next")) + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + _ = try await throwingValue(of: lateScope) } @Test -func transportCommandBackendDecodesRuntimePropertiesPreviewAndCollectionEntries() async throws { +func structuredNetworkScopeLateAcquireDuringDisableReenablesAfterCleanup() async throws { let backend = FakeTransportBackend() let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) await installPageTarget(in: transport) - let target = pageTarget(proxy: WebInspectorProxy(backend: LiveWebInspectorProxyBackend(transport: transport))) - let objectID = Runtime.RemoteObject.ID("object-1") - - let propertiesTask = Task { - try await target.runtime.properties(of: objectID) + let proxy = try await WebInspectorProxy(transport: transport) + let firstGate = CloseConnectionGate() + let lateGate = CloseConnectionGate() + + let firstScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await firstGate.waitUntilReleased() + } } - let propertiesCommand = try await waitForTargetMessage(backend, method: "Runtime.getProperties") + let firstEnable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetReply( transport, - targetID: propertiesCommand.targetIdentifier, - messageID: try messageID(propertiesCommand.message), - result: #"{"properties":[{"name":"answer","value":{"type":"number","value":42,"description":"42"},"writable":true,"isOwn":true},{"name":"accessor","get":{"type":"function","objectId":"getter-1","description":"get answer"},"set":{"type":"undefined","description":"undefined"},"wasThrown":false,"configurable":true,"enumerable":false,"symbol":{"type":"symbol","description":"Symbol(answer)"},"isPrivate":false,"nativeGetter":true}],"internalProperties":[{"name":"[[Prototype]]","value":{"type":"object","objectId":"prototype-1","description":"Object"}}]}"# + targetID: firstEnable.targetIdentifier, + messageID: try messageID(firstEnable.message), + result: "{}" ) + await firstGate.waitUntilStarted() - let properties = try await propertiesTask.value - #expect(properties.count == 2) - #expect(properties[0].name == "answer") - #expect(properties[0].value?.kind == .number) - #expect(properties[0].value?.value == .number(42)) - #expect(properties[0].writable == true) - #expect(properties[0].isOwn == true) - #expect(properties[1].name == "accessor") - #expect(properties[1].get?.kind == .function) - #expect(properties[1].get?.id == Runtime.RemoteObject.ID("getter-1")) - #expect(properties[1].set?.kind == .undefined) - #expect(properties[1].wasThrown == false) - #expect(properties[1].configurable == true) - #expect(properties[1].enumerable == false) - #expect(properties[1].symbol?.kind == .symbol) - #expect(properties[1].symbol?.description == "Symbol(answer)") - #expect(properties[1].isPrivate == false) - #expect(properties[1].nativeGetter == true) + await firstGate.release() + let firstDisable = try await waitForTargetMessage(backend, method: "Network.disable") + await transport.waitForEventScopeCountForTesting(0) - let previewTask = Task { - try await target.runtime.preview(of: objectID) + let lateScope = Task { + try await proxy.page.network.withEvents { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await lateGate.waitUntilReleased() + } } - let previewCommand = try await waitForTargetMessage(backend, method: "Runtime.getPreview") + await transport.waitForEventScopeCountForTesting(1) + await receiveTargetReply( transport, - targetID: previewCommand.targetIdentifier, - messageID: try messageID(previewCommand.message), - result: #"{"preview":{"type":"object","subtype":"map","description":"Map(1)","lossless":true,"overflow":false,"properties":[{"name":"size","type":"number","value":"1"}],"entries":[{"key":{"type":"string","description":"key","lossless":true},"value":{"type":"number","description":"42","lossless":true}}],"size":1}}"# + targetID: firstDisable.targetIdentifier, + messageID: try messageID(firstDisable.message), + result: "{}" ) + _ = try await throwingValue(of: firstScope) - let preview = try await previewTask.value - #expect(preview.kind == .object) - #expect(preview.subtype == Runtime.Subtype(rawValue: "map")) - #expect(preview.description == "Map(1)") - #expect(preview.lossless == true) - #expect(preview.overflow == false) - #expect(preview.properties.first?.name == "size") - #expect(preview.properties.first?.value == "1") - #expect(preview.entries.first?.key == "key") - #expect(preview.entries.first?.value == "42") - #expect(preview.size == 1) + let secondEnable = try await waitForTargetMessage( + backend, + method: "Network.enable", + ordinal: 1 + ) + await receiveTargetReply( + transport, + targetID: secondEnable.targetIdentifier, + messageID: try messageID(secondEnable.message), + result: "{}" + ) + await lateGate.waitUntilStarted() - let entriesTask = Task { - try await target.runtime.collectionEntries(of: objectID) + let messagesBeforeLateRelease = await backend.sentTargetMessages() + let disableCount = try messagesBeforeLateRelease.filter { + try messageMethod($0.message) == "Network.disable" + }.count + #expect(disableCount == 1) + + await lateGate.release() + let finalDisable = try await waitForTargetMessage( + backend, + method: "Network.disable", + ordinal: 1 + ) + await receiveTargetReply( + transport, + targetID: finalDisable.targetIdentifier, + messageID: try messageID(finalDisable.message), + result: "{}" + ) + _ = try await throwingValue(of: lateScope) +} + +@Test +func structuredNetworkScopeOverflowTerminatesOnlyStalledSubscriber() async throws { + let backend = FakeTransportBackend() + let transport = TransportSession(backend: backend, responseTimeout: .milliseconds(750)) + await installPageTarget(in: transport) + let proxy = try await WebInspectorProxy(transport: transport) + let stalledGate = CloseConnectionGate() + let peerGate = CloseConnectionGate() + let peerConsumedGate = CloseConnectionGate() + + let stalled = Task { + do { + try await proxy.page.network.withEvents(buffering: .bounded(2)) { events in + await stalledGate.waitUntilReleased() + for try await _ in events {} + } + return StructuredScopeOutcome.succeeded + } catch WebInspectorProxyError.eventBufferOverflow(capacity: 2) { + return .overflow + } catch { + return .other(String(describing: error)) + } } - let entriesCommand = try await waitForTargetMessage(backend, method: "Runtime.getCollectionEntries") + let peer = Task { + try await proxy.page.network.withEvents(buffering: .bounded(2)) { events in + var iterator = events.makeAsyncIterator() + _ = try await iterator.next() + await peerGate.waitUntilReleased() + let first = try await iterator.next() + let second = try await iterator.next() + await peerConsumedGate.waitUntilReleased() + let third = try await iterator.next() + return (first, second, third) + } + } + + let enable = try await waitForTargetMessage(backend, method: "Network.enable") await receiveTargetReply( transport, - targetID: entriesCommand.targetIdentifier, - messageID: try messageID(entriesCommand.message), - result: #"{"entries":[{"key":{"type":"string","value":"key","description":"key"},"value":{"type":"object","objectId":"entry-value","description":"entry value"}},{"value":{"type":"number","value":42,"description":"42"}}]}"# + targetID: enable.targetIdentifier, + messageID: try messageID(enable.message), + result: "{}" ) + await stalledGate.waitUntilStarted() + await peerGate.waitUntilStarted() - let entries = try await entriesTask.value - #expect(entries.count == 2) - #expect(entries[0].key?.value == .string("key")) - #expect(entries[0].value.id == Runtime.RemoteObject.ID("entry-value")) - #expect(entries[0].value.description == "entry value") - #expect(entries[1].key == nil) - #expect(entries[1].value.value == .number(42)) + for ordinal in 1...2 { + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"overflow-\#(ordinal)","timestamp":\#(ordinal)}"# + ) + } + await peerGate.release() + await peerConsumedGate.waitUntilStarted() + await receiveTargetEvent( + transport, + targetID: enable.targetIdentifier, + method: "Network.loadingFinished", + params: #"{"requestId":"overflow-3","timestamp":3}"# + ) + await peerConsumedGate.release() + await stalledGate.release() + + let disable = try await waitForTargetMessage(backend, method: "Network.disable") + await receiveTargetReply( + transport, + targetID: disable.targetIdentifier, + messageID: try messageID(disable.message), + result: "{}" + ) + + #expect(await stalled.value == .overflow) + let (first, second, third) = try await throwingValue(of: peer) + guard case let .event(_, .loadingFinished(firstID, _, _, _))? = first, + case let .event(_, .loadingFinished(secondID, _, _, _))? = second, + case let .event(_, .loadingFinished(thirdID, _, _, _))? = third else { + Issue.record("Expected the peer subscriber to receive all events.") + return + } + #expect(firstID == Network.Request.ID("overflow-1")) + #expect(secondID == Network.Request.ID("overflow-2")) + #expect(thirdID == Network.Request.ID("overflow-3")) } private func pageTarget(proxy: WebInspectorProxy) -> WebInspectorTarget { @@ -2158,6 +3256,37 @@ private func pageTarget(proxy: WebInspectorProxy) -> WebInspectorTarget { ) } +private func requireCapabilityReplyPurpose( + in transport: TransportSession, + message: SentTargetMessage, + expectedGeneration: WebInspectorPage.Generation +) async throws -> UInt64 { + let pendingKey = TransportSession.PendingKey.target( + TransportSession.ReplyKey( + targetID: message.targetIdentifier, + commandID: try messageID(message.message) + ) + ) + let purposes = await transport.pendingReplyPurposes() + #expect(purposes.count == 1) + let purpose = try #require(purposes[pendingKey]) + switch purpose { + case .direct, .elementPickerMode, .modelCommand, .capabilityAuxiliary: + Issue.record("Expected a capability reply purpose.") + return 0 + case .modelBootstrap: + Issue.record("Expected a capability reply purpose.") + return 0 + case let .capability(key, generation, operationID): + #expect(key.route == .currentPage) + #expect(key.targetID == .currentPage) + #expect(key.domain == .network) + #expect(generation == expectedGeneration) + #expect(operationID > 0) + return operationID + } +} + private func installPageTarget( in transport: TransportSession, targetID: ProtocolTarget.ID = ProtocolTarget.ID("page-main"), @@ -2172,13 +3301,6 @@ private func installPageTarget( ) } -private func waitForEventSubscription( - _ target: WebInspectorTarget, - domain: WebInspectorProxyEventDomain -) async { - await target.proxy.waitForEventSubscription(targetID: target.id, route: target.route, domain: domain) -} - private func receiveTargetEvent( _ transport: TransportSession, targetID: ProtocolTarget.ID, @@ -2229,6 +3351,19 @@ private func receiveTargetReply( )) } +private func receiveTargetError( + _ transport: TransportSession, + targetID: ProtocolTarget.ID, + messageID: UInt64, + message: String +) async { + let escapedMessage = jsonEscapedString(message) + await transport.receiveRootMessage(targetDispatchMessage( + targetID: targetID, + message: #"{"id":\#(messageID),"error":{"message":"\#(escapedMessage)"}}"# + )) +} + private func targetDispatchMessage( targetID: ProtocolTarget.ID, message: String @@ -2272,6 +3407,44 @@ private func messageObject(_ message: String) throws -> [String: Any] { return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) } +private actor SuspendedSendTransportBackend: TransportBackend { + private var sendStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var sendContinuations: [CheckedContinuation] = [] + + func sendJSONString(_ message: String) async throws { + _ = message + sendStarted = true + let waiters = startWaiters + startWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + await withCheckedContinuation { continuation in + sendContinuations.append(continuation) + } + } + + func detach() async {} + + func waitUntilSendStarted() async { + guard sendStarted == false else { + return + } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func releaseSend() { + let continuations = sendContinuations + sendContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} + private actor CloseConnectionGate { private var started = false private var released = false @@ -2368,6 +3541,22 @@ private actor EventDeliveryProbe { private struct TimedOut: Error {} +private struct StructuredScopeBodyFailure: Error {} + +private enum StructuredScopeOutcome: Equatable, Sendable { + case succeeded + case cancelled + case bodyFailure + case cleanupFailure + case combinedFailure + case overflow + case transportFailure + case protocolViolation + case pageUnavailable + case timeout + case other(String) +} + private func value( of task: Task, timeout: Duration = .seconds(5) diff --git a/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift new file mode 100644 index 00000000..6f2a5be0 --- /dev/null +++ b/Tests/WebInspectorProxyKitTests/WebInspectorTestPeerTests.swift @@ -0,0 +1,473 @@ +import Testing +import WebInspectorProxyKit +@testable import WebInspectorProxyKitTesting +@testable import WebInspectorTestSupport + +@Test +func testJSONObjectCanonicalizesObjectEquality() throws { + let first = try WebInspectorTestJSONObject(json: #"{"b":2,"a":1}"#) + let second = try WebInspectorTestJSONObject(json: "{ \"a\" : 1, \"b\" : 2 }") + + #expect(first == second) + #expect(try first.decode(JSONFixture.self) == JSONFixture(a: 1, b: 2)) + #expect(throws: WebInspectorTestPeerError.invalidJSONObject) { + try WebInspectorTestJSONObject(json: "[1, 2]") + } +} + +@Test +func peerDeliversRootCommandsFIFOAndRepliesExactlyOnce() async throws { + let peer = WebInspectorTestPeer() + let core = await peer.makeConnection(configuration: .init()) + + let firstTask = Task { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.first", + routing: .root + )) + } + let first = try await peer.commands.next() + #expect(first.destination == .root) + #expect(first.method == "Target.first") + + let secondTask = Task { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.second", + routing: .root + )) + } + let second = try await peer.commands.next() + #expect(second.destination == .root) + #expect(second.method == "Target.second") + + try await peer.reply(to: first) + try await peer.reply(to: second) + _ = try await firstTask.value + _ = try await secondTask.value + await #expect(throws: WebInspectorTestPeerError.commandAlreadyCompleted) { + try await peer.reply(to: first) + } + await core.close() +} + +@Test +func targetedReplyCompletesOuterAndInnerCorrelation() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let target = try await runtime.proxy.waitForCurrentPage() + let operation = Task { + try await target.page.reload() + } + + let command = try await runtime.peer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "Page.reload") + try await runtime.peer.reply(to: command) + try await operation.value + await runtime.close() +} + +@Test +func commandAndEventAdmissionRejectCoreCloseBeforePeerDetach() async throws { + let peer = WebInspectorTestPeer() + let core = await peer.makeConnection(configuration: .init()) + let operation = Task { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.pending", + routing: .root + )) + } + let command = try await peer.commands.next() + + let closeActionEntered = WebInspectorTestGate() + let permitClose = WebInspectorTestGate() + await core.replaceCloseActionForTesting { + closeActionEntered.open() + await permitClose.waiter.wait() + } + let close = Task { + await core.close() + } + await closeActionEntered.waiter.wait() + + await #expect(throws: WebInspectorTestPeerError.staleCommand) { + try await peer.reply(to: command) + } + await #expect(throws: WebInspectorTestPeerError.connectionClosed) { + try await peer.emitRootEvent(method: "Target.late") + } + + permitClose.open() + await close.value + await #expect(throws: TransportSession.Error.self) { + try await operation.value + } +} + +@Test +func completedReplyRemainsSuccessfulWhenOwnerClosesAfterDrain() async throws { + let peer = WebInspectorTestPeer() + let core = await peer.makeConnection(configuration: .init()) + let operation = Task { + try await core.send(ProtocolCommand( + domain: .target, + method: "Target.final", + routing: .root + )) + } + let command = try await peer.commands.next() + await peer.setPostDrainActionForTesting { + await core.close() + } + + try await peer.reply(to: command) + + _ = try await operation.value + #expect(await core.terminalCause == .explicitClose) +} + +@Test +func peerRejectsForeignAndStaleCorrelationsWithoutTombstones() async throws { + let firstRuntime = try await WebInspectorProxyTestRuntime.start() + let secondRuntime = try await WebInspectorProxyTestRuntime.start() + let firstTarget = try await firstRuntime.proxy.waitForCurrentPage() + + let operation = Task { + try await firstTarget.page.reload() + } + let command = try await firstRuntime.peer.commands.next() + await #expect(throws: WebInspectorTestPeerError.foreignCommand) { + try await secondRuntime.peer.reply(to: command) + } + + await firstRuntime.peer.closeConnection() + await #expect(throws: WebInspectorTestPeerError.staleCommand) { + try await firstRuntime.peer.reply(to: command) + } + await #expect(throws: WebInspectorProxyError.closed) { + try await operation.value + } + await secondRuntime.close() +} + +@Test +func cancellingNextCommandWaiterDoesNotConsumeTheNextWireCommand() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let target = try await runtime.proxy.waitForCurrentPage() + let cancelledWaiter = Task { + try await runtime.peer.commands.next() + } + cancelledWaiter.cancel() + await #expect(throws: CancellationError.self) { + try await cancelledWaiter.value + } + + let operation = Task { + try await target.page.reload() + } + let command = try await runtime.peer.commands.next() + #expect(command.method == "Page.reload") + try await runtime.peer.reply(to: command) + try await operation.value + await runtime.close() +} + +@Test +func suspendedNextCommandWaiterDoesNotRetainUnattachedPeer() async throws { + var peer: WebInspectorTestPeer? = WebInspectorTestPeer() + weak let weakPeer = peer + let waiter = Task { [weak peer] in + guard let commands = peer?.commands else { + return nil as WebInspectorTestPeer.Command? + } + return try await commands.next() + } + + while peer?.commands.pendingWaiterCountForTesting == 0 { + await Task.yield() + } + peer = nil + + let didReleasePeer = await eventually { weakPeer == nil } + #expect(didReleasePeer) + if !didReleasePeer { + waiter.cancel() + } + await #expect(throws: WebInspectorTestPeerError.connectionClosed) { + try await waiter.value + } +} + +@Test +func peerTerminationWinsWhenItPrecedesCommandWaiterRegistration() async throws { + var peer: WebInspectorTestPeer? = WebInspectorTestPeer() + weak let weakPeer = peer + let commands = try #require(peer).commands + let waiterAllocated = WebInspectorTestGate() + let permitRegistration = WebInspectorTestGate() + let waiter = Task { + try await commands.nextForTesting { + waiterAllocated.open() + await permitRegistration.waiter.wait() + } + } + + await waiterAllocated.waiter.wait() + peer = nil + #expect(weakPeer == nil) + permitRegistration.open() + + await #expect(throws: WebInspectorTestPeerError.connectionClosed) { + try await waiter.value + } +} + +@Test +func testGateOpenAndTaskCancellationLinearizeBeforeWaiterRegistration() async throws { + let openFirstGate = WebInspectorTestGate() + let openFirstAllocated = NonCancellableBarrier() + let permitOpenFirstRegistration = NonCancellableBarrier() + let openFirstWaiter = Task { + try await openFirstGate.waiter.waitUntilOpenForTesting { + await openFirstAllocated.open() + await permitOpenFirstRegistration.wait() + } + } + await openFirstAllocated.wait() + openFirstGate.open() + openFirstWaiter.cancel() + await permitOpenFirstRegistration.open() + try await openFirstWaiter.value + + let cancellationFirstGate = WebInspectorTestGate() + let cancellationFirstAllocated = NonCancellableBarrier() + let permitCancellationFirstRegistration = NonCancellableBarrier() + let cancellationFirstWaiter = Task { + try await cancellationFirstGate.waiter.waitUntilOpenForTesting { + await cancellationFirstAllocated.open() + await permitCancellationFirstRegistration.wait() + } + } + await cancellationFirstAllocated.wait() + cancellationFirstWaiter.cancel() + cancellationFirstGate.open() + await permitCancellationFirstRegistration.open() + await #expect(throws: CancellationError.self) { + try await cancellationFirstWaiter.value + } +} + +@Test +func testGateWaitHandleDoesNotRetainController() async throws { + var gate: WebInspectorTestGate? = WebInspectorTestGate() + weak let weakGate = gate + let waiter = try #require(gate).waiter + let task = Task { + await waiter.wait() + } + + while waiter.pendingWaiterCountForTesting == 0 { + await Task.yield() + } + gate = nil + + #expect(weakGate == nil) + await task.value + #expect(waiter.pendingWaiterCountForTesting == 0) +} + +@Test +func proxyOwnsRawPeerAfterRuntimeWrapperIsReleased() async throws { + var runtime: WebInspectorProxyTestRuntime? = try await .start() + var proxy: WebInspectorProxy? = try #require(runtime).proxy + weak let weakPeer = try #require(runtime).peer + runtime = nil + + #expect(weakPeer != nil) + do { + let retainedProxy = try #require(proxy) + let retainedPeer = try #require(weakPeer) + let operation = Task { + try await retainedProxy.page.page.reload() + } + let command = try await retainedPeer.commands.next() + #expect(command.destination == .target("page-main")) + #expect(command.method == "Page.reload") + try await retainedPeer.reply(to: command) + try await operation.value + } + + await proxy?.close() + proxy = nil + #expect(await eventually { weakPeer == nil }) +} + +@Test +func rawWireDriverConsumesLaterCommandsWhileAnEarlierReplyIsDeferred() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let target = try await runtime.proxy.waitForCurrentPage() + let driver = WebInspectorRawWireDriver(peer: runtime.peer) + await driver.start() + let reloadGate = await driver.deferReply(to: "Page.reload") + await driver.respond(to: "DOM.hideHighlight") + + let reload = Task { + try await target.page.reload() + } + _ = await driver.observations.waitForCommands(method: "Page.reload", count: 1) + + let hideHighlight = Task { + try await target.dom.hideHighlight() + } + _ = await driver.observations.waitForCompletedCommands(method: "DOM.hideHighlight", count: 1) + try await hideHighlight.value + + #expect(driver.observations.commandMethods == [ + "Page.reload", + "DOM.hideHighlight", + ]) + reloadGate.open() + try await reload.value + + await runtime.close() + await driver.stop() +} + +@Test +func rawWireDriverStopCancelsAndAwaitsDeferredReplies() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let target = try await runtime.proxy.waitForCurrentPage() + let driver = WebInspectorRawWireDriver(peer: runtime.peer) + await driver.start() + _ = await driver.deferReply(to: "Page.reload") + + let reload = Task { + try await target.page.reload() + } + _ = await driver.observations.waitForCommands(method: "Page.reload", count: 1) + + await driver.stop() + await runtime.close() + await #expect(throws: WebInspectorProxyError.closed) { + try await reload.value + } +} + +@Test +func rawWireDriverConsumerTaskDoesNotRetainIdleDriver() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + weak var weakDriver: WebInspectorRawWireDriver? + + do { + let driver = WebInspectorRawWireDriver(peer: runtime.peer) + weakDriver = driver + await driver.start() + } + + #expect(weakDriver == nil) + await runtime.close() +} + +@Test +func suspendedRawWireObservationDoesNotRetainDriver() async { + let peer = WebInspectorTestPeer() + var driver: WebInspectorRawWireDriver? = WebInspectorRawWireDriver(peer: peer) + weak let weakDriver = driver + let waiter = Task { [weak driver] () -> [WebInspectorTestPeer.Command] in + guard let observations = driver?.observations else { + return [] + } + return await observations.waitForCommands(method: "Page.reload", count: 1) + } + + while driver?.observations.pendingWaiterCountForTesting == 0 { + await Task.yield() + } + driver = nil + + let didReleaseDriver = await eventually { weakDriver == nil } + #expect(didReleaseDriver) + if !didReleaseDriver { + waiter.cancel() + } + #expect(await waiter.value.isEmpty) +} + +@Test +func stoppedRawWireDriverReleasesDeferredReplyTask() async throws { + let runtime = try await WebInspectorProxyTestRuntime.start() + let target = try await runtime.proxy.waitForCurrentPage() + weak var weakDriver: WebInspectorRawWireDriver? + var reload: Task? + + do { + let driver = WebInspectorRawWireDriver(peer: runtime.peer) + weakDriver = driver + await driver.start() + _ = await driver.deferReply(to: "Page.reload") + reload = Task { + try await target.page.reload() + } + _ = await driver.observations.waitForCommands(method: "Page.reload", count: 1) + await driver.stop() + } + + #expect(weakDriver == nil) + await runtime.close() + let reloadTask = try #require(reload) + await #expect(throws: WebInspectorProxyError.closed) { + try await reloadTask.value + } +} + +private struct JSONFixture: Codable, Equatable, Sendable { + let a: Int + let b: Int +} + +private func eventually( + timeout: Duration = .seconds(1), + _ predicate: () -> Bool +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while !predicate() { + guard clock.now < deadline else { + return false + } + await Task.yield() + } + return true +} + +private actor NonCancellableBarrier { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + if isOpen { + continuation.resume() + } else { + waiters.append(continuation) + } + } + } + + func open() { + guard !isOpen else { + return + } + isOpen = true + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Tests/WebInspectorTestSupport/WebInspectorRawFixtures.swift b/Tests/WebInspectorTestSupport/WebInspectorRawFixtures.swift new file mode 100644 index 00000000..1b2684d7 --- /dev/null +++ b/Tests/WebInspectorTestSupport/WebInspectorRawFixtures.swift @@ -0,0 +1,98 @@ +import WebInspectorProxyKit +import WebInspectorProxyKitTesting + +/// Encodes a test fixture as one validated top-level protocol object. +public func webInspectorTestJSONObject( + _ value: Value +) throws -> WebInspectorTestJSONObject { + try WebInspectorTestJSONObject(encoding: value) +} + +/// Validates a literal top-level protocol object. +public func webInspectorTestJSONObject( + _ json: String +) throws -> WebInspectorTestJSONObject { + try WebInspectorTestJSONObject(json: json) +} + +/// Creates a raw `DOM.getDocument` result from a typed ProxyKit node tree. +public func webInspectorDOMDocumentResult( + _ document: DOM.Node +) throws -> WebInspectorTestJSONObject { + try webInspectorTestJSONObject( + WebInspectorDOMDocumentWireResult(root: WebInspectorDOMNodeWire(document)) + ) +} + +/// Raw WebKit encoding for a typed DOM node test fixture. +public final class WebInspectorDOMNodeWire: Encodable { + let nodeId: String + let nodeType: Int + let nodeName: String + let localName: String + let nodeValue: String + let frameId: String? + let childNodeCount: Int + let children: [WebInspectorDOMNodeWire]? + let attributes: [String] + let documentURL: String? + let baseURL: String? + let pseudoType: String? + let shadowRootType: String? + let contentDocument: WebInspectorDOMNodeWire? + let shadowRoots: [WebInspectorDOMNodeWire] + let templateContent: WebInspectorDOMNodeWire? + let pseudoElements: [WebInspectorDOMNodeWire] + + public init(_ node: DOM.Node) { + nodeId = node.id.rawValue + nodeType = node.nodeType + nodeName = node.nodeName + localName = node.localName + nodeValue = node.nodeValue + frameId = node.frameID?.rawValue + childNodeCount = node.childNodeCount + children = node.children?.map(Self.init) + attributes = node.attributeList.flatMap { [$0.name, $0.value] } + documentURL = node.documentURL + baseURL = node.baseURL + pseudoType = node.pseudoType.map(Self.pseudoType) + shadowRootType = node.shadowRootType.map(Self.shadowRootType) + contentDocument = node.contentDocument.map(Self.init) + shadowRoots = node.shadowRoots.map(Self.init) + templateContent = node.templateContent.map(Self.init) + pseudoElements = ( + [node.beforePseudoElement].compactMap { $0 } + + node.otherPseudoElements + + [node.afterPseudoElement].compactMap { $0 } + ).map(Self.init) + } + + private static func pseudoType(_ value: DOM.PseudoType) -> String { + switch value { + case .before: + "before" + case .after: + "after" + case let .other(rawValue): + rawValue + } + } + + private static func shadowRootType(_ value: DOM.ShadowRootType) -> String { + switch value { + case .open: + "open" + case .closed: + "closed" + case .userAgent: + "user-agent" + case let .other(rawValue): + rawValue + } + } +} + +private struct WebInspectorDOMDocumentWireResult: Encodable { + let root: WebInspectorDOMNodeWire +} diff --git a/Tests/WebInspectorTestSupport/WebInspectorRawWireDriver.swift b/Tests/WebInspectorTestSupport/WebInspectorRawWireDriver.swift new file mode 100644 index 00000000..8f6046fd --- /dev/null +++ b/Tests/WebInspectorTestSupport/WebInspectorRawWireDriver.swift @@ -0,0 +1,481 @@ +import Foundation +import Synchronization +import Testing +import WebInspectorProxyKitTesting + +/// Scripted replies and command observations layered on the public raw peer. +/// +/// This test-only coordinator never injects event sequences, generations, or +/// target membership. Every command still traverses ProxyKit's production +/// connection core and receives exactly one raw peer reply or failure. +public actor WebInspectorRawWireDriver { + /// Immutable command snapshots and lifecycle-safe asynchronous waiters. + /// + /// This value owns only the driver's observation broker. Suspending on it + /// never retains the driver actor, so dropping the actor finishes pending + /// waiters with the commands observed so far. + public struct Observations: Sendable { + fileprivate let broker: WebInspectorRawCommandObservationBroker + + fileprivate init(broker: WebInspectorRawCommandObservationBroker) { + self.broker = broker + } + + public var commands: [WebInspectorTestPeer.Command] { + broker.recordedCommands() + } + + public var commandMethods: [String] { + commands.map(\.method) + } + + public func waitForCommands( + method: String, + count: Int + ) async -> [WebInspectorTestPeer.Command] { + await broker.waitForCommands(method: method, count: count) + } + + /// Waits until Core drains the matching replies or failures. + public func waitForCompletedCommands( + method: String, + count: Int + ) async -> [WebInspectorTestPeer.Command] { + await broker.waitForCompletedCommands(method: method, count: count) + } + + public var pendingWaiterCountForTesting: Int { + broker.pendingWaiterCount + } + } + + private enum Response: Sendable { + case result(WebInspectorTestJSONObject) + case failure(String) + indirect case deferred(UUID, WebInspectorTestGate.Waiter, Response) + } + + private let peer: WebInspectorTestPeer + private nonisolated let observationBroker: WebInspectorRawCommandObservationBroker + public nonisolated let observations: Observations + private var responses: [String: [Response]] = [:] + private var gates: [UUID: WebInspectorTestGate] = [:] + private var task: Task? + private var replyTasks: [UUID: Task] = [:] + private var hasStarted = false + + public init(peer: WebInspectorTestPeer) { + let observationBroker = WebInspectorRawCommandObservationBroker() + self.peer = peer + self.observationBroker = observationBroker + observations = Observations(broker: observationBroker) + } + + isolated deinit { + // Awaited gate cancellation and peer shutdown belong to stop(). The + // external observation broker makes this synchronous backstop + // reachable even while clients are suspended waiting for commands. + task?.cancel() + for replyTask in replyTasks.values { + replyTask.cancel() + } + observationBroker.finish() + } + + public func start() { + precondition(!hasStarted, "WebInspectorRawWireDriver can start only once.") + hasStarted = true + let commands = peer.commands + task = Self.makeConsumerTask(commands: commands, driver: self) + } + + private nonisolated static func makeConsumerTask( + commands: WebInspectorTestPeer.Commands, + driver: WebInspectorRawWireDriver + ) -> Task { + Task { [weak driver] in + while !Task.isCancelled { + do { + let command = try await commands.next() + guard !Task.isCancelled else { + return + } + guard let driver else { + return + } + await driver.scheduleReply(for: command) + } catch is CancellationError { + return + } catch WebInspectorTestPeerError.connectionClosed { + return + } catch { + Issue.record("Raw Web Inspector wire driver failed: \(error)") + return + } + } + } + } + + public func respond( + to method: String, + with result: WebInspectorTestJSONObject = .empty + ) { + enqueue(.result(result), for: method) + } + + public func fail(_ method: String, message: String) { + enqueue(.failure(message), for: method) + } + + public func deferReply( + to method: String, + with result: WebInspectorTestJSONObject = .empty + ) -> WebInspectorTestGate { + deferResponse(.result(result), to: method) + } + + public func deferFailure( + to method: String, + message: String + ) -> WebInspectorTestGate { + deferResponse(.failure(message), to: method) + } + + public func emitRootEvent( + method: String, + parameters: WebInspectorTestJSONObject = .empty + ) async throws { + try await peer.emitRootEvent(method: method, parameters: parameters) + } + + public func emitTargetEvent( + targetID: String, + method: String, + parameters: WebInspectorTestJSONObject = .empty + ) async throws { + try await peer.emitTargetEvent( + targetID: targetID, + method: method, + parameters: parameters + ) + } + + public func stop() async { + let runningTask = task + task = nil + runningTask?.cancel() + let activeGates = gates.values + gates.removeAll(keepingCapacity: false) + for gate in activeGates { + gate.cancel() + } + let activeReplyTasks = Array(replyTasks.values) + replyTasks.removeAll(keepingCapacity: false) + for replyTask in activeReplyTasks { + replyTask.cancel() + } + await runningTask?.value + for replyTask in activeReplyTasks { + await replyTask.value + } + observationBroker.finish() + } + + private func scheduleReply(for command: WebInspectorTestPeer.Command) { + guard task != nil else { + return + } + observationBroker.record(command) + let method = command.method + let response: Response + if var queued = responses[method], !queued.isEmpty { + response = queued.removeFirst() + responses[method] = queued.isEmpty ? nil : queued + } else { + Issue.record("Unexpected raw Web Inspector command: \(method)") + response = .failure("No raw test reply registered for \(method).") + } + + let replyID = UUID() + let peer = self.peer + replyTasks[replyID] = Self.makeReplyTask( + peer: peer, + command: command, + response: response, + replyID: replyID, + driver: self + ) + } + + private nonisolated static func makeReplyTask( + peer: WebInspectorTestPeer, + command: WebInspectorTestPeer.Command, + response: Response, + replyID: UUID, + driver: WebInspectorRawWireDriver + ) -> Task { + Task { [weak driver] in + do { + let resolved = try await Self.resolve(response) + switch resolved { + case let .result(result): + try await peer.reply(to: command, with: result) + case let .failure(message): + try await peer.fail(command, message: message) + case .deferred: + preconditionFailure("Nested deferred raw responses are not supported.") + } + await driver?.complete(command) + } catch is CancellationError { + // Explicit stop owns cancellation and awaits this task. + } catch WebInspectorTestPeerError.connectionClosed { + // Connection teardown is terminal for outstanding replies. + } catch WebInspectorTestPeerError.staleCommand { + // Connection teardown invalidated this correlation. + } catch { + Issue.record("Raw Web Inspector wire reply failed: \(error)") + } + await driver?.finishReply(id: replyID, response: response) + } + } + + private nonisolated static func resolve(_ response: Response) async throws -> Response { + switch response { + case let .deferred(_, waiter, deferredResponse): + try await waiter.waitUntilOpen() + return deferredResponse + case .result, .failure: + return response + } + } + + private func finishReply(id: UUID, response: Response) { + if case let .deferred(gateID, _, _) = response { + gates[gateID] = nil + } + replyTasks[id] = nil + } + + private func enqueue(_ response: Response, for method: String) { + responses[method, default: []].append(response) + } + + private func deferResponse( + _ response: Response, + to method: String + ) -> WebInspectorTestGate { + let gate = WebInspectorTestGate() + let gateID = UUID() + gates[gateID] = gate + enqueue(.deferred(gateID, gate.waiter, response), for: method) + return gate + } + + private func complete(_ command: WebInspectorTestPeer.Command) { + observationBroker.recordCompletion(command) + } +} + +private final class WebInspectorRawCommandObservationBroker: Sendable { + private enum Collection: Sendable { + case received + case completed + } + + private struct Waiter: Sendable { + let id: UInt64 + let collection: Collection + let method: String + let count: Int + let continuation: CheckedContinuation<[WebInspectorTestPeer.Command], Never> + } + + private struct State: Sendable { + var received: [WebInspectorTestPeer.Command] = [] + var completed: [WebInspectorTestPeer.Command] = [] + var waiters: [Waiter] = [] + var registeringWaiterIDs: Set = [] + var nextWaiterID: UInt64 = 0 + var isFinished = false + } + + private enum RegistrationAction { + case wait + case resume([WebInspectorTestPeer.Command]) + } + + private let state = Mutex(State()) + + func recordedCommands() -> [WebInspectorTestPeer.Command] { + state.withLock { $0.received } + } + + var pendingWaiterCount: Int { + state.withLock { $0.waiters.count } + } + + func record(_ command: WebInspectorTestPeer.Command) { + append(command, to: .received) + } + + func recordCompletion(_ command: WebInspectorTestPeer.Command) { + append(command, to: .completed) + } + + func waitForCommands( + method: String, + count: Int + ) async -> [WebInspectorTestPeer.Command] { + await wait(for: .received, method: method, count: count) + } + + func waitForCompletedCommands( + method: String, + count: Int + ) async -> [WebInspectorTestPeer.Command] { + await wait(for: .completed, method: method, count: count) + } + + func finish() { + let resumptions = state.withLock { state -> [( + continuation: CheckedContinuation<[WebInspectorTestPeer.Command], Never>, + commands: [WebInspectorTestPeer.Command] + )] in + guard !state.isFinished else { + return [] + } + state.isFinished = true + state.registeringWaiterIDs.removeAll(keepingCapacity: false) + let resumptions = state.waiters.map { waiter in + (waiter.continuation, Self.matches(for: waiter, in: state)) + } + state.waiters.removeAll(keepingCapacity: false) + return resumptions + } + for resumption in resumptions { + resumption.continuation.resume(returning: resumption.commands) + } + } + + private func append( + _ command: WebInspectorTestPeer.Command, + to collection: Collection + ) { + let resumptions = state.withLock { state -> [( + continuation: CheckedContinuation<[WebInspectorTestPeer.Command], Never>, + commands: [WebInspectorTestPeer.Command] + )] in + guard !state.isFinished else { + return [] + } + switch collection { + case .received: + state.received.append(command) + case .completed: + state.completed.append(command) + } + + var pending: [Waiter] = [] + var resumptions: [( + continuation: CheckedContinuation<[WebInspectorTestPeer.Command], Never>, + commands: [WebInspectorTestPeer.Command] + )] = [] + for waiter in state.waiters { + let matches = Self.matches(for: waiter, in: state) + if matches.count >= waiter.count { + resumptions.append((waiter.continuation, matches)) + } else { + pending.append(waiter) + } + } + state.waiters = pending + return resumptions + } + for resumption in resumptions { + resumption.continuation.resume(returning: resumption.commands) + } + } + + private func wait( + for collection: Collection, + method: String, + count: Int + ) async -> [WebInspectorTestPeer.Command] { + precondition(count > 0, "A raw command waiter count must be positive.") + let waiterID = state.withLock { state -> UInt64 in + precondition( + state.nextWaiterID < UInt64.max, + "The raw command observation broker exhausted its waiter identifier space." + ) + state.nextWaiterID += 1 + let waiterID = state.nextWaiterID + state.registeringWaiterIDs.insert(waiterID) + return waiterID + } + + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let action = state.withLock { state -> RegistrationAction in + let matches = Self.matches( + collection: collection, + method: method, + in: state + ) + if state.registeringWaiterIDs.remove(waiterID) == nil + || state.isFinished + || matches.count >= count { + return .resume(matches) + } + state.waiters.append(Waiter( + id: waiterID, + collection: collection, + method: method, + count: count, + continuation: continuation + )) + return .wait + } + if case let .resume(commands) = action { + continuation.resume(returning: commands) + } + } + } onCancel: { + let resumption = state.withLock { state -> ( + continuation: CheckedContinuation<[WebInspectorTestPeer.Command], Never>, + commands: [WebInspectorTestPeer.Command] + )? in + guard let index = state.waiters.firstIndex(where: { $0.id == waiterID }) else { + state.registeringWaiterIDs.remove(waiterID) + return nil + } + let waiter = state.waiters.remove(at: index) + return (waiter.continuation, Self.matches(for: waiter, in: state)) + } + if let resumption { + resumption.continuation.resume(returning: resumption.commands) + } + } + } + + private static func matches( + for waiter: Waiter, + in state: State + ) -> [WebInspectorTestPeer.Command] { + matches(collection: waiter.collection, method: waiter.method, in: state) + } + + private static func matches( + collection: Collection, + method: String, + in state: State + ) -> [WebInspectorTestPeer.Command] { + let commands: [WebInspectorTestPeer.Command] + switch collection { + case .received: + commands = state.received + case .completed: + commands = state.completed + } + return commands.filter { $0.method == method } + } +} diff --git a/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift b/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift new file mode 100644 index 00000000..2281cda7 --- /dev/null +++ b/Tests/WebInspectorTestSupport/WebInspectorTestGate.swift @@ -0,0 +1,157 @@ +import Synchronization + +/// A deterministic, cancellation-aware suspension point for asynchronous tests. +public final class WebInspectorTestGate: Sendable { + /// A wait handle that owns only gate state, not the gate owner itself. + public struct Waiter: Sendable { + fileprivate let storage: Storage + + fileprivate init(storage: Storage) { + self.storage = storage + } + + /// Suspends until the gate opens or is cancelled. + public func wait() async { + _ = try? await WebInspectorTestGate.waitUntilOpen(storage) + } + + func waitUntilOpen() async throws { + try await WebInspectorTestGate.waitUntilOpen(storage) + } + + func waitUntilOpenForTesting( + afterWaiterAllocation action: @escaping @Sendable () async -> Void + ) async throws { + try await WebInspectorTestGate.waitUntilOpen( + storage, + afterWaiterAllocation: action + ) + } + + var pendingWaiterCountForTesting: Int { + storage.state.withLock { $0.waiters.count } + } + } + + fileprivate final class Storage: Sendable { + struct State: Sendable { + var isOpen = false + var isCancelled = false + var waiters: [UInt64: CheckedContinuation] = [:] + var registeringWaiterIDs: Set = [] + var nextWaiterID: UInt64 = 0 + } + + let state = Mutex(State()) + } + + private enum RegistrationAction { + case wait + case open + case cancelled + } + + private let storage: Storage + public let waiter: Waiter + + public init() { + let storage = Storage() + self.storage = storage + waiter = Waiter(storage: storage) + } + + deinit { + Self.cancel(storage) + } + + /// Opens the gate and resumes every current and future waiter successfully. + public func open() { + let storage = storage + let waiters = storage.state.withLock { state -> [CheckedContinuation] in + guard !state.isOpen, !state.isCancelled else { + return [] + } + state.isOpen = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resume() + } + } + + /// Cancels the gate and resumes every current and future waiter with cancellation. + public func cancel() { + Self.cancel(storage) + } + + private static func waitUntilOpen( + _ storage: Storage, + afterWaiterAllocation: (@Sendable () async -> Void)? = nil + ) async throws { + try Task.checkCancellation() + let waiterID = storage.state.withLock { state -> UInt64 in + precondition( + state.nextWaiterID < UInt64.max, + "WebInspectorTestGate exhausted its waiter identifier space." + ) + state.nextWaiterID += 1 + let waiterID = state.nextWaiterID + state.registeringWaiterIDs.insert(waiterID) + return waiterID + } + + try await withTaskCancellationHandler { + await afterWaiterAllocation?() + try await withCheckedThrowingContinuation { continuation in + let action = storage.state.withLock { state -> RegistrationAction in + if state.registeringWaiterIDs.remove(waiterID) == nil || state.isCancelled { + return .cancelled + } + if state.isOpen { + return .open + } + state.waiters[waiterID] = continuation + return .wait + } + switch action { + case .wait: + break + case .open: + continuation.resume() + case .cancelled: + continuation.resume(throwing: CancellationError()) + } + } + } onCancel: { + let waiter = storage.state.withLock { state -> CheckedContinuation? in + guard !state.isOpen, !state.isCancelled else { + return nil + } + guard let waiter = state.waiters.removeValue(forKey: waiterID) else { + state.registeringWaiterIDs.remove(waiterID) + return nil + } + return waiter + } + waiter?.resume(throwing: CancellationError()) + } + } + + private static func cancel(_ storage: Storage) { + let waiters = storage.state.withLock { state -> [CheckedContinuation] in + guard !state.isOpen, !state.isCancelled else { + return [] + } + state.isCancelled = true + state.registeringWaiterIDs.removeAll(keepingCapacity: false) + let waiters = Array(state.waiters.values) + state.waiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resume(throwing: CancellationError()) + } + } +} diff --git a/Tests/WebInspectorUITests/CustomTabResourceTests.swift b/Tests/WebInspectorUITests/CustomTabResourceTests.swift new file mode 100644 index 00000000..04b2a969 --- /dev/null +++ b/Tests/WebInspectorUITests/CustomTabResourceTests.swift @@ -0,0 +1,164 @@ +#if canImport(UIKit) +import Testing +import UIKit +import WebInspectorDataKit +import WebInspectorTestSupport +@testable import WebInspectorUI + +@MainActor +@Suite +struct CustomTabResourceTests { + private struct FactoryFailure: LocalizedError { + var errorDescription: String? { + "Custom tab bootstrap failed." + } + } + + @Test + func concurrentHostsJoinOneFactoryAndMoveReadyContent() async throws { + let key = WebInspectorTab.ContentKey(tabID: "console", contentID: "root") + let started = WebInspectorTestGate() + let release = WebInspectorTestGate() + let session = WebInspectorSession(tabs: []) + let store = PresentationContentStore() + let content = UIViewController() + var factoryCallCount = 0 + let make: @MainActor (WebInspectorSession) async throws -> UIViewController = { _ in + factoryCallCount += 1 + started.open() + await release.waiter.wait() + return content + } + + let firstHost = store.customViewController( + for: key, + session: session, + makeViewController: make + ) + let secondHost = store.customViewController( + for: key, + session: session, + makeViewController: make + ) + + #expect(firstHost.phase == .loading) + #expect(secondHost.phase == .loading) + await started.waiter.wait() + #expect(factoryCallCount == 1) + + release.open() + await store.waitForCustomResourceTaskForTesting(for: key) + + #expect(store.customResourceStatusForTesting(for: key) == .ready) + #expect(store.customReadyViewControllerForTesting(for: key) === content) + #expect(firstHost.phase == .ready) + #expect(secondHost.phase == .ready) + #expect(firstHost.readyViewControllerForTesting == nil) + #expect(secondHost.readyViewControllerForTesting === content) + #expect(content.parent === secondHost) + + let replacementHost = store.customViewController( + for: key, + session: session, + makeViewController: make + ) + #expect(factoryCallCount == 1) + #expect(replacementHost.readyViewControllerForTesting === content) + #expect(content.parent === replacementHost) + + await store.clear() + } + + @Test + func failureRendersRetryAndRetryPublishesReadyContent() async { + let key = WebInspectorTab.ContentKey(tabID: "failing", contentID: "root") + let session = WebInspectorSession(tabs: []) + let store = PresentationContentStore() + let content = UIViewController() + var factoryCallCount = 0 + let make: @MainActor (WebInspectorSession) async throws -> UIViewController = { _ in + factoryCallCount += 1 + if factoryCallCount == 1 { + throw FactoryFailure() + } + return content + } + + let host = store.customViewController( + for: key, + session: session, + makeViewController: make + ) + await store.waitForCustomResourceTaskForTesting(for: key) + + #expect(host.phase == .failed("Custom tab bootstrap failed.")) + #expect( + store.customResourceStatusForTesting(for: key) + == .failed("Custom tab bootstrap failed.") + ) + #expect( + (host.contentUnavailableConfiguration as? UIContentUnavailableConfiguration)? + .button.title == "Retry" + ) + + host.retryForTesting() + await store.waitForCustomResourceTaskForTesting(for: key) + + #expect(factoryCallCount == 2) + #expect(host.phase == .ready) + #expect(host.readyViewControllerForTesting === content) + await store.clear() + } + + @Test + func factoryTaskDoesNotRetainStoreAndLateCompletionCannotPublish() async throws { + let key = WebInspectorTab.ContentKey(tabID: "lifecycle", contentID: "root") + let started = WebInspectorTestGate() + let release = WebInspectorTestGate() + let finished = WebInspectorTestGate() + let session = WebInspectorSession(tabs: []) + var store: PresentationContentStore? = PresentationContentStore() + weak let weakStore = store + let host = try #require(store).customViewController( + for: key, + session: session + ) { _ in + started.open() + await release.waiter.wait() + finished.open() + return UIViewController() + } + await started.waiter.wait() + + store = nil + + #expect(weakStore == nil) + #expect(host.phase == .loading) + #expect(host.readyViewControllerForTesting == nil) + + release.open() + await finished.waiter.wait() + #expect(host.readyViewControllerForTesting == nil) + } + + @Test + func sessionConfigurationUnionsTabAndAdditionalDomains() { + let console = WebInspectorTab( + id: "console", + title: "Console", + requiredDomains: [.console, .css] + ) { _ in + UIViewController() + } + let session = WebInspectorSession( + tabs: [.network, console], + additionalDomains: [.runtime] + ) + + #expect( + session.model.configuredDomains + == [.network, .console, .css, .dom, .runtime] + ) + } +} +#endif diff --git a/Tests/WebInspectorUITests/DOMContainerTests.swift b/Tests/WebInspectorUITests/DOMContainerTests.swift index 2a54d62f..adcdde0b 100644 --- a/Tests/WebInspectorUITests/DOMContainerTests.swift +++ b/Tests/WebInspectorUITests/DOMContainerTests.swift @@ -97,6 +97,27 @@ struct DOMContainerTests { #expect(propertyView(named: "margin", in: propertyViews)?.declarationFontForTesting?.pointSize == UIFont.preferredFont(forTextStyle: .body).pointSize) } + @Test + func elementViewControllerRendersStylesLoadedImmediatelyAfterSelection() async throws { + let context = makeElementContext() + let viewController = makeElementViewController(context: context) + let window = showInWindow(viewController) + defer { window.isHidden = true } + + _ = try selectElement(named: "body", in: context) + applyBodyStyles(to: context) + + let didRenderRows = await waitUntilRendered(in: viewController) { + viewController.contentUnavailableConfiguration == nil + && stylePropertyViews(in: viewController) + .contains { $0.declarationTextForTesting == "margin: 0;" } + } + + #expect(didRenderRows) + #expect(viewController.collectionView.numberOfSections == 1) + #expect(viewController.collectionView.numberOfItems(inSection: 0) == 3) + } + @Test func elementStyleSectionHeaderTextFormatsRuleOriginText() { let stylesheetLocation = DOMElementStyleSectionHeaderText.SourceLocation( @@ -160,12 +181,9 @@ struct DOMContainerTests { #expect(didUpdateVisibleRow) #expect(viewController.collectionView.isHidden == false) #expect(visibleCellIDs(in: viewController) == cellIDsBeforeUpdate) - // Value-type rows cannot self-observe: the same-identity content - // change surfaces as exactly one reconfigure apply that keeps the - // existing cells (the legacy build re-rendered in place with no - // snapshot apply at all). - #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeUpdate + 1) - #expect(viewController.lastSnapshotApplyModeForTesting == .diff(animated: false)) + // Property Observation updates the existing row. A content-only + // change never reaches the collection snapshot owner. + #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeUpdate) } @Test @@ -287,8 +305,10 @@ struct DOMContainerTests { .contains("margin: 0;") } #expect(didRenderBodyRows) + let applyCountBeforeSelection = viewController.styleSnapshotApplyCountForTesting + let cellIDsBeforeSelection = visibleCellIDs(in: viewController) - context.select(input) + try context.selectDOMNode(input) applyBodyStyles( to: context, selector: "input", @@ -303,7 +323,8 @@ struct DOMContainerTests { } #expect(didRenderInputRows) - #expect(viewController.lastSnapshotApplyModeForTesting == .reloadData) + #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeSelection) + #expect(visibleCellIDs(in: viewController) == cellIDsBeforeSelection) } @Test @@ -323,8 +344,10 @@ struct DOMContainerTests { } window.layoutIfNeeded() #expect(didRenderBodyRows) + let applyCountBeforeSelection = viewController.styleSnapshotApplyCountForTesting + let cellIDsBeforeSelection = visibleCellIDs(in: viewController) - let body = try #require(context.selectedNode) + let body = try #require(try context.selectedDOMNode) let input = try selectElement(named: "input", in: context) // Seed once to cancel the preview context's backend-less refresh // task, then hold the fresh selection in `.loading` so the pending @@ -366,7 +389,8 @@ struct DOMContainerTests { #expect(didRenderInputRows) #expect(viewController.collectionView.isHidden == false) - #expect(viewController.lastSnapshotApplyModeForTesting == .reloadData) + #expect(viewController.styleSnapshotApplyCountForTesting == applyCountBeforeSelection) + #expect(visibleCellIDs(in: viewController) == cellIDsBeforeSelection) } @Test @@ -376,8 +400,7 @@ struct DOMContainerTests { let window = showInWindow(viewController) defer { window.isHidden = true } - let body = try selectElement(named: "body", in: context) - #expect(body.elementStyles?.phase == .loading) + _ = try selectElement(named: "body", in: context) let didRenderPlaceholder = await waitUntilRendered(in: viewController) { viewController.contentUnavailableConfiguration != nil @@ -405,11 +428,10 @@ struct DOMContainerTests { } #expect(didRenderBodyRows) - let input = try selectElement(named: "input", in: context) - #expect(input.elementStyles?.phase == .loading) + _ = try selectElement(named: "input", in: context) #expect(stylePropertyViews(in: viewController).map(\.declarationTextForTesting).contains("margin: 0;")) - context.select(nil) + try context.selectDOMNode(nil) let didClearRows = await waitUntilRendered(in: viewController) { viewController.contentUnavailableConfiguration != nil @@ -764,7 +786,7 @@ struct DOMContainerTests { } @Test - func elementStylePropertyViewSendsToggleActionWithImmediateControlFeedback() { + func elementStylePropertyViewSendsToggleActionWithImmediateControlFeedback() async { let propertyID = CSSStyleProperty.ID("test-style:0") let property = CSSStyleProperty( id: propertyID, @@ -777,9 +799,11 @@ struct DOMContainerTests { let propertyView = DOMElementStylePropertyView() var requestedPropertyID: CSSStyleProperty.ID? var requestedEnabled: Bool? - propertyView.bind(property: property) { propertyID, enabled in - requestedPropertyID = propertyID + let acceptedRequest = WebInspectorTestGate() + propertyView.bind(property: property) { property, enabled in + requestedPropertyID = property.id requestedEnabled = enabled + acceptedRequest.open() return true } let window = showViewInWindow(propertyView) @@ -787,14 +811,19 @@ struct DOMContainerTests { propertyView.tapToggleForTesting() + #expect(propertyView.isToggleOnForTesting == false) + await acceptedRequest.waiter.wait() #expect(requestedPropertyID == propertyID) #expect(requestedEnabled == false) - #expect(propertyView.isToggleOnForTesting == false) + let rejectedRequest = WebInspectorTestGate() propertyView.bind(property: property) { _, _ in - false + rejectedRequest.open() + return false } propertyView.tapToggleForTesting() + await rejectedRequest.waiter.wait() + await Task.yield() #expect(propertyView.isToggleOnForTesting == true) } @@ -893,44 +922,48 @@ struct DOMContainerTests { @Test func navigationDeleteRegistersDOMUndoRedoAfterSuccessfulBackendDelete() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) #expect(undoManager.canUndo) - await fixture.runtime.backend.enqueue((), for: "DOM", method: "undo") + await fixture.wire.respond(to: "DOM.undo") undoManager.undo() - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "undo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "undo", count: 1) let didEnableRedo = await waitForDOMRedoAvailability(true, undoManager: undoManager) #expect(didEnableRedo) - await fixture.runtime.backend.enqueue((), for: "DOM", method: "redo") + await fixture.wire.respond(to: "DOM.redo") navigationItems.redoForTesting(undoManager: undoManager) - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "redo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "redo", count: 1) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo", "redo"]) } @Test func navigationDeleteDoesNotRegisterUndoWhenBackendDeleteFails() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) + await fixture.wire.fail( + "DOM.removeNode", + message: "Intentional remove failure." + ) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode"]) #expect(!undoManager.canUndo) } @@ -938,19 +971,23 @@ struct DOMContainerTests { @Test func navigationUndoDoesNotRegisterRedoWhenBackendUndoFails() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) + await fixture.wire.fail( + "DOM.undo", + message: "Intentional undo failure." + ) undoManager.undo() - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "undo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "undo", count: 1) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo"]) #expect(!navigationItems.canRedoForTesting(undoManager: undoManager)) } @@ -958,18 +995,18 @@ struct DOMContainerTests { @Test func navigationDOMRedoClearsWhenUndoManagerRegistersAnotherAction() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) - await fixture.runtime.backend.enqueue((), for: "DOM", method: "undo") + await fixture.wire.respond(to: "DOM.undo") undoManager.undo() - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "undo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "undo", count: 1) let didEnableRedo = await waitForDOMRedoAvailability(true, undoManager: undoManager) #expect(didEnableRedo) @@ -982,35 +1019,33 @@ struct DOMContainerTests { navigationItems.redoForTesting(undoManager: undoManager) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo"]) } @Test func navigationPendingDOMRedoClearsWhenUndoManagerRegistersAnotherActionBeforeUndoCompletes() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) - let undoGate = WebInspectorTestGate() - await fixture.runtime.backend.hold(domain: "DOM", method: "undo", gate: undoGate) - await fixture.runtime.backend.enqueue((), for: "DOM", method: "undo") + let undoGate = await fixture.wire.deferReply(to: "DOM.undo") let operationBaseline = DOMDeletionUndoRegistration.operationCompletionCountForTesting(on: undoManager) undoManager.undo() - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "undo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "undo", count: 1) let marker = UndoRegistrationMarker() undoManager.beginUndoGrouping() undoManager.registerUndo(withTarget: marker) { _ in } undoManager.endUndoGrouping() - await undoGate.open() + undoGate.open() let didFinishUndo = await DOMDeletionUndoRegistration.waitForOperationCompletionForTesting( after: operationBaseline, on: undoManager @@ -1020,32 +1055,36 @@ struct DOMContainerTests { navigationItems.redoForTesting(undoManager: undoManager) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo"]) } @Test func navigationRedoDoesNotRegisterUndoWhenBackendRedoFails() async throws { let fixture = try await makeLiveDOMContext() - let input = try #require(fixture.context.node(for: DOMNode.ID(DOM.Node.ID("input")))) - fixture.context.select(input) + let input = try #require(try fixture.context.domNode(id: DOMNode.ID(DOM.Node.ID("input")))) + try fixture.context.selectDOMNode(input) let undoManager = UndoManager() undoManager.groupsByEvent = false let navigationItems = DOMNavigationItems(context: fixture.context) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await navigationItems.deleteSelectedNodeForTesting(undoManager: undoManager) - await fixture.runtime.backend.enqueue((), for: "DOM", method: "undo") + await fixture.wire.respond(to: "DOM.undo") undoManager.undo() - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "undo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "undo", count: 1) let didEnableRedo = await waitForDOMRedoAvailability(true, undoManager: undoManager) #expect(didEnableRedo) + await fixture.wire.fail( + "DOM.redo", + message: "Intentional redo failure." + ) navigationItems.redoForTesting(undoManager: undoManager) - _ = await recordedDOMCommands(on: fixture.runtime.backend, method: "redo", count: 1) + _ = await recordedDOMCommands(on: fixture.wire, method: "redo", count: 1) - let commands = await fixture.runtime.backend.recordedCommands() + let commands = fixture.wire.observations.commands #expect(commands.domMutationUndoMethods == ["removeNode", "undo", "redo"]) #expect(!undoManager.canUndo) } @@ -1062,22 +1101,22 @@ struct DOMContainerTests { let treeView = viewController.displayedDOMTreeTextViewForTesting #expect(await treeView.waitForRowDocumentForTesting()) - await enqueueDOMRemoveNodeWithUndoMark(on: fixture.runtime.backend) + await enqueueDOMRemoveNodeWithUndoMark(on: fixture.wire) await treeView.deleteRowFromMenuForTesting(containing: " WebInspectorContext { - WebInspectorContext.preview(isolation: MainActor.shared) + private func makeWebInspectorContext() -> WebInspectorModelContext { + WebInspectorModelContext.preview() } private struct LiveDOMContextFixture { var runtime: WebInspectorProxyTestRuntime - var context: WebInspectorContext + var wire: WebInspectorRawWireDriver + var context: WebInspectorModelContext } private func makeLiveDOMContext(document: DOM.Node? = nil) async throws -> LiveDOMContextFixture { let runtime = try await WebInspectorProxyTestRuntime.start() - let target = try await runtime.proxy.waitForCurrentPage() - await enqueueLiveStartupReplies(on: runtime.backend, document: document ?? documentNode()) - let container = WebInspectorContainer(proxy: runtime.proxy) - let context = container.mainContext - try await waitForLiveStartupSubscribers(runtime: runtime, target: target) - let didAttach = await waitForAttachedState(in: context) - try #require(didAttach) - return LiveDOMContextFixture(runtime: runtime, context: context) - } - - private func enqueueLiveStartupReplies(on backend: WebInspectorTestBackend, document: DOM.Node) async { - await backend.enqueue((), for: "Inspector", method: "enable") - await backend.enqueue((), for: "Inspector", method: "initialized") - await backend.enqueue((), for: "Runtime", method: "enable") - await backend.enqueue((), for: "Network", method: "enable") - await backend.enqueue(document, for: "DOM", method: "getDocument") - await backend.enqueue((), for: "Console", method: "enable") - } - - private func waitForLiveStartupSubscribers( - runtime: WebInspectorProxyTestRuntime, - target: WebInspectorTarget + let wire = WebInspectorRawWireDriver(peer: runtime.peer) + await wire.start() + try await enqueueLiveStartupReplies(on: wire, document: document ?? documentNode()) + let context = WebInspectorModelContext() + try await context.attach(to: runtime.proxy, isolation: MainActor.shared) + #expect(context.state == .attached) + return LiveDOMContextFixture(runtime: runtime, wire: wire, context: context) + } + + private func enqueueLiveStartupReplies( + on wire: WebInspectorRawWireDriver, + document: DOM.Node ) async throws { - try await runtime.backend.waitForSubscribers(domain: "DOM", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Inspector", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "CSS", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Network", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Console", target: target, count: 1) - try await runtime.backend.waitForSubscribers(domain: "Runtime", target: target, count: 1) - } - - private func waitForAttachedState(in context: WebInspectorContext) async -> Bool { - if context.state == .attached { - return true - } - for await status in context.statusUpdates { - if status.state == .attached { - return true - } - if status.state != .attaching { - return false - } - } - return context.state == .attached + await wire.respond(to: "Page.enable") + await wire.respond(to: "CSS.enable") + await wire.respond(to: "Network.enable") + await wire.respond(to: "Console.enable") + await wire.respond(to: "Runtime.enable") + await wire.respond( + to: "DOM.getDocument", + with: try webInspectorDOMDocumentResult(document) + ) + await wire.fail( + "CSS.getMatchedStylesForNode", + message: "Styles are intentionally unavailable in the DOM navigation fixture." + ) } private func waitForDOMRedoAvailability(_ isAvailable: Bool, undoManager: UndoManager?) async -> Bool { @@ -1420,39 +1452,39 @@ struct DOMContainerTests { } private func recordedDOMCommands( - on backend: WebInspectorTestBackend, + on wire: WebInspectorRawWireDriver, method: String, count: Int - ) async -> [RecordedCommand] { - await backend.waitForRecordedCommands(domain: "DOM", method: method, count: count) + ) async -> [WebInspectorTestPeer.Command] { + await wire.observations.waitForCommands(method: "DOM.\(method)", count: count) } - private func enqueueDOMRemoveNodeWithUndoMark(on backend: WebInspectorTestBackend) async { - await backend.enqueue((), for: "DOM", method: "removeNode") - await backend.enqueue((), for: "DOM", method: "markUndoableState") + private func enqueueDOMRemoveNodeWithUndoMark(on wire: WebInspectorRawWireDriver) async { + await wire.respond(to: "DOM.removeNode") + await wire.respond(to: "DOM.markUndoableState") } - private func makeElementContext() -> WebInspectorContext { + private func makeElementContext() -> WebInspectorModelContext { let context = makeWebInspectorContext() context.seedDOMDocument(documentNode()) return context } - private func makeElementViewController(context: WebInspectorContext) -> DOMElementViewController { + private func makeElementViewController(context: WebInspectorModelContext) -> DOMElementViewController { let viewController = DOMElementViewController(context: context) viewController.disablesSnapshotAnimationsForTesting = true return viewController } - private func selectElement(named localName: String, in context: WebInspectorContext) throws -> DOMNode { + private func selectElement(named localName: String, in context: WebInspectorModelContext) throws -> DOMNode { let node = try #require(DOMPreviewFixtures.firstElement(named: localName, in: context)) - context.select(node) + try context.selectDOMNode(node) return node } @discardableResult private func applyBodyStyles( - to context: WebInspectorContext, + to context: WebInspectorModelContext, selector: String = "body", sourceURL: String = "styles.css", sourceLine: Int = 1, @@ -1512,7 +1544,7 @@ struct DOMContainerTests { } private func applyInheritedVariableStyles( - to context: WebInspectorContext, + to context: WebInspectorModelContext, bodyColorValue: String = "var(--foreground)", foregroundValue: String = "var(--palette-primary)", additionalBodyProperties: [PropertySpec] = [], @@ -1943,14 +1975,18 @@ struct DOMContainerTests { } } -private extension Array where Element == RecordedCommand { +private extension Array where Element == WebInspectorTestPeer.Command { var domMutationUndoMethods: [String] { filter { command in - command.domain == "DOM" && ["removeNode", "undo", "redo"].contains(command.method) + ["DOM.removeNode", "DOM.undo", "DOM.redo"].contains(command.method) } - .map(\.method) + .map { String($0.method.dropFirst("DOM.".count)) } } } +private struct DOMHighlightNodeParameters: Decodable, Sendable { + let nodeId: String +} + private final class UndoRegistrationMarker: NSObject {} #endif diff --git a/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift b/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift index fbea57b6..2d5bc580 100644 --- a/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift +++ b/Tests/WebInspectorUITests/DOMElementStylePresentationStateTests.swift @@ -11,7 +11,7 @@ extension WebInspectorUIRenderingTests { @Suite struct DOMElementStyleSnapshotCoordinatorTests { /// Keeps the weak `CSSStyles.modelContext` alive for the test lifetime. - private let modelContext = WebInspectorContext.preview(isolation: MainActor.shared) + private let modelContext = WebInspectorModelContext.preview() @Test func coordinatorRequestsNonAnimatedDiffForInitialLoadedSelection() throws { @@ -30,7 +30,7 @@ struct DOMElementStyleSnapshotCoordinatorTests { } @Test - func coordinatorReloadsWhenSwitchingToCachedSelectionStyles() throws { + func coordinatorDiffsWhenSwitchingToDifferentCachedPropertyIdentities() throws { let coordinator = DOMElementStyleSnapshotCoordinator() let bodyStyles = makeStyles(nodeID: "node-body") load(bodyStyles, with: makeFlatMatchedStyles()) @@ -51,13 +51,14 @@ struct DOMElementStyleSnapshotCoordinatorTests { let update = coordinator.updateSelectedNodeStyles(inputStyles) - #expect(update.applyMode == .reloadData) + #expect(update.applyMode == .diff(animated: false)) + #expect(update.rebindVisiblePropertyRows) #expect(update.placeholderMode == .none) #expect(try loadedSnapshot(from: update).sectionIdentifiers == coordinator.visibleSectionIDs) } @Test - func coordinatorReloadsSelectionReplacementWithMatchingDiffableIdentifiers() { + func coordinatorRebindsSelectionReplacementWithMatchingDiffableIdentifiers() { let coordinator = DOMElementStyleSnapshotCoordinator() let oldStyles = makeStyles() load(oldStyles, with: makeFlatMatchedStyles()) @@ -76,7 +77,9 @@ struct DOMElementStyleSnapshotCoordinatorTests { let update = coordinator.updateSelectedNodeStyles(replacementStyles) - #expect(update.applyMode == .reloadData) + #expect(update.applyMode == .none) + #expect(update.snapshot == nil) + #expect(update.rebindVisiblePropertyRows) #expect(update.placeholderMode == .none) } @@ -109,7 +112,8 @@ struct DOMElementStyleSnapshotCoordinatorTests { ) let loadedUpdate = coordinator.updateSelectedNodeStyles(inputStyles) - #expect(loadedUpdate.applyMode == .reloadData) + #expect(loadedUpdate.applyMode == .diff(animated: false)) + #expect(loadedUpdate.rebindVisiblePropertyRows) #expect(loadedUpdate.placeholderMode == .none) } @@ -130,16 +134,19 @@ struct DOMElementStyleSnapshotCoordinatorTests { #expect(containsVisibleProperty(named: "color", in: snapshot, coordinator: coordinator)) } - /// Value-world replacement for the legacy in-place property mutation - /// policy: rows no longer observe property objects, so a same-identity - /// content change must surface as a reconfigure apply. + /// Property content belongs to the stable observable property. The + /// collection snapshot remains a topology-only artifact. @Test - func coordinatorReconfiguresRowsForPropertyContentChangeWithoutStructuralChange() throws { + func coordinatorDoesNotApplySnapshotForObservablePropertyContentChange() throws { let coordinator = DOMElementStyleSnapshotCoordinator() let nodeStyles = makeStyles() load(nodeStyles, with: makeFlatMatchedStyles()) coordinator.bindSelectedNodeStyles(nodeStyles) - let initialSnapshot = try loadedSnapshot(from: coordinator.updateSelectedNodeStyles(nodeStyles)) + let initialUpdate = coordinator.updateSelectedNodeStyles(nodeStyles) + let initialSnapshot = try loadedSnapshot(from: initialUpdate) + let initialItem = try #require(initialSnapshot.itemIdentifiers.first) + let initialSection = try #require(coordinator.section(for: initialItem.sectionID)) + let initialProperty = try #require(coordinator.property(for: initialItem, in: initialSection)) load( nodeStyles, @@ -150,16 +157,40 @@ struct DOMElementStyleSnapshotCoordinatorTests { ) let update = coordinator.updateSelectedNodeStyles(nodeStyles) - #expect(update.applyMode == .diff(animated: false)) + #expect(update.applyMode == .none) + #expect(update.snapshot == nil) #expect(update.placeholderMode == .none) - let snapshot = try loadedSnapshot(from: update) - #expect(snapshot.sectionIdentifiers == initialSnapshot.sectionIdentifiers) - #expect(snapshot.itemIdentifiers == initialSnapshot.itemIdentifiers) - let reconfiguredItems = snapshot.reconfiguredItemIdentifiers - #expect(reconfiguredItems.count == 1) - let reconfiguredItem = try #require(reconfiguredItems.first) - let section = try #require(coordinator.section(for: reconfiguredItem.sectionID)) - #expect(coordinator.property(for: reconfiguredItem, in: section)?.name == "margin") + let updatedSection = try #require(coordinator.section(for: initialItem.sectionID)) + let updatedProperty = try #require(coordinator.property(for: initialItem, in: updatedSection)) + #expect(updatedProperty === initialProperty) + #expect(updatedProperty.value == "4px") + #expect(updatedProperty.text == "margin: 4px;") + } + + @Test + func coordinatorRebindsWithoutSnapshotWhenPositionalIDsAreReusedAfterReorder() throws { + let coordinator = DOMElementStyleSnapshotCoordinator() + let nodeStyles = makeStyles() + load(nodeStyles, with: makeFlatMatchedStyles()) + coordinator.bindSelectedNodeStyles(nodeStyles) + let initialSnapshot = try loadedSnapshot( + from: coordinator.updateSelectedNodeStyles(nodeStyles) + ) + let firstItem = try #require(initialSnapshot.itemIdentifiers.first) + let initialSection = try #require(coordinator.section(for: firstItem.sectionID)) + let oldFirstProperty = try #require(coordinator.property(for: firstItem, in: initialSection)) + #expect(oldFirstProperty.name == "margin") + + load(nodeStyles, with: makeReorderedFlatMatchedStyles()) + let update = coordinator.updateSelectedNodeStyles(nodeStyles) + + #expect(update.applyMode == .none) + #expect(update.snapshot == nil) + #expect(update.rebindVisiblePropertyRows) + let reorderedSection = try #require(coordinator.section(for: firstItem.sectionID)) + let newFirstProperty = try #require(coordinator.property(for: firstItem, in: reorderedSection)) + #expect(newFirstProperty.name == "padding") + #expect(newFirstProperty !== oldFirstProperty) } /// DataKit's `applySetStyleText` rewrites sections in place and marks @@ -167,12 +198,15 @@ struct DOMElementStyleSnapshotCoordinatorTests { /// modified-by-inspector badge must reach the rows before the follow-up /// refresh lands. @Test - func coordinatorReconfiguresSameSelectionContentDuringNeedsRefresh() throws { + func coordinatorDoesNotApplySnapshotForPropertyContentDuringNeedsRefresh() throws { let coordinator = DOMElementStyleSnapshotCoordinator() let nodeStyles = makeStyles() load(nodeStyles, with: makeFlatMatchedStyles()) coordinator.bindSelectedNodeStyles(nodeStyles) let initialSnapshot = try loadedSnapshot(from: coordinator.updateSelectedNodeStyles(nodeStyles)) + let initialItem = try #require(initialSnapshot.itemIdentifiers.first) + let initialSection = try #require(coordinator.section(for: initialItem.sectionID)) + let initialProperty = try #require(coordinator.property(for: initialItem, in: initialSection)) load( nodeStyles, @@ -184,11 +218,12 @@ struct DOMElementStyleSnapshotCoordinatorTests { nodeStyles.markNeedsRefresh() let update = coordinator.updateSelectedNodeStyles(nodeStyles) - #expect(update.applyMode == .diff(animated: false)) - let snapshot = try loadedSnapshot(from: update) - #expect(snapshot.sectionIdentifiers == initialSnapshot.sectionIdentifiers) - #expect(snapshot.itemIdentifiers == initialSnapshot.itemIdentifiers) - #expect(snapshot.reconfiguredItemIdentifiers.count == 1) + #expect(update.applyMode == .none) + #expect(update.snapshot == nil) + let updatedSection = try #require(coordinator.section(for: initialItem.sectionID)) + let updatedProperty = try #require(coordinator.property(for: initialItem, in: updatedSection)) + #expect(updatedProperty === initialProperty) + #expect(updatedProperty.text == "/* margin: 4px; */") } /// Structure stays frozen while the styles are stale; structural changes @@ -431,6 +466,27 @@ struct DOMElementStyleSnapshotCoordinatorTests { ) } + private func makeReorderedFlatMatchedStyles() -> CSS.MatchedStyles { + let styleID = "style-flat" + let style = CSS.Style( + id: CSS.Style.ID(styleID), + properties: [ + property(id: "\(styleID):0", name: "padding", value: "8px", text: "padding: 8px;"), + property(id: "\(styleID):1", name: "margin", value: "0", text: "margin: 0;"), + ], + cssText: "padding: 8px;\nmargin: 0;", + isEditable: true + ) + return CSS.MatchedStyles(matchedRules: [ + CSS.Rule( + id: CSS.Rule.ID("rule-flat"), + selectorList: CSS.Rule.SelectorList(selectors: ["body"], text: "body"), + origin: CSS.Origin(rawValue: "author"), + style: style + ), + ]) + } + private func property( id: String, name: String, diff --git a/Tests/WebInspectorUITests/DOMTreeMenuModelTests.swift b/Tests/WebInspectorUITests/DOMTreeMenuModelTests.swift index 1f231d37..26640b4c 100644 --- a/Tests/WebInspectorUITests/DOMTreeMenuModelTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeMenuModelTests.swift @@ -153,19 +153,19 @@ struct DOMTreeMenuModelTests { } private struct DOMTreeMenuModelFixture { - var context: WebInspectorContext + var context: WebInspectorModelContext var divID: DOMNode.ID var inputID: DOMNode.ID } @MainActor private func makeMenuFixture() throws -> DOMTreeMenuModelFixture { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() context.seedDOMDocument(menuFixtureDocument()) let divID = DOMNode.ID(DOM.Node.ID("div")) let inputID = DOMNode.ID(DOM.Node.ID("input")) - _ = try #require(context.node(for: divID)) - _ = try #require(context.node(for: inputID)) + _ = try #require(try context.domNode(id: divID)) + _ = try #require(try context.domNode(id: inputID)) return DOMTreeMenuModelFixture(context: context, divID: divID, inputID: inputID) } diff --git a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift index 8431a5b9..4f7b486c 100644 --- a/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift +++ b/Tests/WebInspectorUITests/DOMTreeTextViewTests.swift @@ -1,6 +1,7 @@ #if canImport(UIKit) import Testing import UIKit +import WebInspectorTestSupport @testable import WebInspectorDataKit @testable import WebInspectorProxyKit @testable import WebInspectorUI @@ -344,6 +345,148 @@ struct DOMTreeTextViewTests { #expect(recorder.recordedOwners == [.selection]) } + @Test + func duplicateSelectionInvalidationCoalescesInFlightPageHighlight() async throws { + let session = makeDOMTreeFixture() + let recorder = ControlledNodeActionRecorder() + let view = DOMTreeTextView( + context: session.context, + highlightNodeAction: { nodeID, owner in + try await recorder.run(nodeID, owner: owner) + } + ) + configureTreeViewForDeterministicTesting(view) + view.frame = CGRect(x: 0, y: 0, width: 360, height: 480) + view.layoutIfNeeded() + view.setRenderingActive(true) + #expect(await view.waitForRowDocumentForTesting()) + + view.primaryClickRowForTesting(containing: "") + await recorder.waitForInvocationCount(1) + #expect(await view.waitForObservedTreeRevisionForTesting(session.treeRevision)) + + view.routeCurrentSelectionInvalidationForTesting() + view.routeCurrentSelectionInvalidationForTesting() + await Task.yield() + + #expect(recorder.invocationCount == 1) + await recorder.resolveInvocation(at: 0, as: .success) + await view.waitForPageHighlightTaskForTesting() + #expect(recorder.recordedOwners == [.selection]) + } + + @Test + func changingSelectionReplacesInFlightPageHighlight() async throws { + let session = makeDOMTreeFixture() + let recorder = ControlledNodeActionRecorder() + let view = DOMTreeTextView( + context: session.context, + highlightNodeAction: { nodeID, owner in + try await recorder.run(nodeID, owner: owner) + } + ) + configureTreeViewForDeterministicTesting(view) + view.frame = CGRect(x: 0, y: 0, width: 360, height: 480) + view.layoutIfNeeded() + view.setRenderingActive(true) + #expect(await view.waitForRowDocumentForTesting()) + + view.primaryClickRowForTesting(containing: "") + await recorder.waitForInvocationCount(1) + let firstNodeID = try #require(session.selectedNode?.id) + + view.primaryClickRowForTesting(containing: "") + await recorder.waitForInvocationCount(1) + let nodeA = try #require(session.selectedNode?.id) + + view.primaryClickRowForTesting(containing: "") + await recorder.waitForInvocationCount(3) + #expect(session.selectedNode?.id == nodeA) + + // Complete stale A1 and B2 after A3 is current. Neither completion + // owns A3's intent, even though A1 has the same semantic node ID. + await recorder.resolveInvocation(at: 0, as: .success) + await recorder.resolveInvocation(at: 1, as: .success) + await recorder.resolveInvocation(at: 2, as: .failure) + await view.waitForPageHighlightTaskForTesting() + + view.routeCurrentSelectionInvalidationForTesting() + await recorder.waitForInvocationCount(4) + #expect(recorder.recordedNodeIDs == [nodeA, nodeB, nodeA, nodeA]) + await recorder.resolveInvocation(at: 3, as: .success) + await view.waitForPageHighlightTaskForTesting() + } + + @Test + func selectionHighlightFailureAllowsLaterInvalidationRetry() async throws { + let session = makeDOMTreeFixture() + let recorder = ControlledNodeActionRecorder() + let view = DOMTreeTextView( + context: session.context, + highlightNodeAction: { nodeID, owner in + try await recorder.run(nodeID, owner: owner) + } + ) + configureTreeViewForDeterministicTesting(view) + view.frame = CGRect(x: 0, y: 0, width: 360, height: 480) + view.layoutIfNeeded() + view.setRenderingActive(true) + #expect(await view.waitForRowDocumentForTesting()) + + view.primaryClickRowForTesting(containing: "") + await recorder.waitForInvocationCount(1) + #expect(await view.waitForObservedTreeRevisionForTesting(session.treeRevision)) + await recorder.resolveInvocation(at: 0, as: .failure) + await view.waitForPageHighlightTaskForTesting() + + view.routeCurrentSelectionInvalidationForTesting() + await recorder.waitForInvocationCount(2) + await recorder.resolveInvocation(at: 1, as: .success) + await view.waitForPageHighlightTaskForTesting() + + #expect(recorder.recordedNodeIDs.count == 2) + #expect(recorder.recordedNodeIDs[0] == recorder.recordedNodeIDs[1]) + #expect(recorder.recordedOwners == [.selection, .selection]) + } + @Test func hoverEndRestoresSelectedPageHighlight() async throws { let session = makeDOMTreeFixture() @@ -1155,6 +1298,153 @@ private final class NodeActionRecorder { } } +@MainActor +private final class ControlledNodeActionRecorder { + enum Resolution { + case success + case failure + } + + private struct IntentionalFailure: Error {} + + private enum Gate { + case cancellationAware(WebInspectorTestGate) + case cancellationIgnoring(CancellationIgnoringGate) + + @MainActor + func wait() async { + switch self { + case let .cancellationAware(gate): + await gate.waiter.wait() + case let .cancellationIgnoring(gate): + await gate.wait() + } + } + + @MainActor + func open() { + switch self { + case let .cancellationAware(gate): + gate.open() + case let .cancellationIgnoring(gate): + gate.open() + } + } + } + + private var nodeIDs: [DOMNode.ID] = [] + private var owners: [DOMTreePageHighlightOwner] = [] + private var gates: [Gate] = [] + private var failedInvocationIndexes: Set = [] + private var invocationWaiters: [( + count: Int, + continuation: CheckedContinuation + )] = [] + private let ignoresCancellation: Bool + + init(ignoresCancellation: Bool = false) { + self.ignoresCancellation = ignoresCancellation + } + + func run(_ nodeID: DOMNode.ID, owner: DOMTreePageHighlightOwner) async throws { + let invocationIndex = nodeIDs.count + let gate: Gate = if ignoresCancellation { + .cancellationIgnoring(CancellationIgnoringGate()) + } else { + .cancellationAware(WebInspectorTestGate()) + } + nodeIDs.append(nodeID) + owners.append(owner) + gates.append(gate) + resumeInvocationWaitersIfNeeded() + + await gate.wait() + if !ignoresCancellation { + try Task.checkCancellation() + } + if failedInvocationIndexes.contains(invocationIndex) { + throw IntentionalFailure() + } + } + + func waitForInvocationCount(_ count: Int) async { + guard nodeIDs.count < count else { + return + } + await withCheckedContinuation { continuation in + if nodeIDs.count >= count { + continuation.resume() + } else { + invocationWaiters.append((count, continuation)) + } + } + } + + func resolveInvocation(at index: Int, as resolution: Resolution) async { + precondition(gates.indices.contains(index), "The controlled highlight invocation must exist before resolution.") + if case .failure = resolution { + failedInvocationIndexes.insert(index) + } + gates[index].open() + } + + var invocationCount: Int { + nodeIDs.count + } + + var recordedNodeIDs: [DOMNode.ID] { + nodeIDs + } + + var recordedOwners: [DOMTreePageHighlightOwner] { + owners + } + + private func resumeInvocationWaitersIfNeeded() { + var pending: [( + count: Int, + continuation: CheckedContinuation + )] = [] + for waiter in invocationWaiters { + if nodeIDs.count >= waiter.count { + waiter.continuation.resume() + } else { + pending.append(waiter) + } + } + invocationWaiters = pending + } +} + +@MainActor +private final class CancellationIgnoringGate { + private var isOpen = false + private var continuation: CheckedContinuation? + + func wait() async { + guard !isOpen else { + return + } + await withCheckedContinuation { continuation in + if isOpen { + continuation.resume() + } else { + precondition(self.continuation == nil, "A controlled highlight gate supports one waiter.") + self.continuation = continuation + } + } + } + + func open() { + guard !isOpen else { + return + } + isOpen = true + continuation?.resume() + continuation = nil + } +} + @MainActor private final class VoidActionRecorder { private(set) var recordCount = 0 @@ -1242,22 +1532,22 @@ private final class CancellableVoidActionRecorder { @MainActor private final class DOMTreeTestFixture { - let context: WebInspectorContext + let context: WebInspectorModelContext let treeController: DOMTreeController init(root: DOM.Node) { - let context = WebInspectorContext.preview(isolation: MainActor.shared) + let context = WebInspectorModelContext.preview() context.seedDOMDocument(root) self.context = context self.treeController = context.rootTreeController() } var currentPageRootNode: DOMNode? { - context.rootNode + try? context.rootDOMNode } var selectedNode: DOMNode? { - context.selectedNode + try? context.selectedDOMNode } var treeRevision: UInt64 { @@ -1270,7 +1560,7 @@ private final class DOMTreeTestFixture { var isSelectingElement: Bool { get { - context.isElementPickerEnabled + (try? context.isElementPickerEnabled) == true } set { context.seedElementPickerEnabled(newValue) diff --git a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift index fd5abd17..f5a6c84f 100644 --- a/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift +++ b/Tests/WebInspectorUITests/NetworkDetailViewControllerTests.swift @@ -5,6 +5,8 @@ import Synchronization import Testing import WebInspectorDataKit import WebInspectorProxyKit +import WebInspectorProxyKitTesting +import WebInspectorTestSupport import UIKit @testable import WebInspectorUI @testable import WebInspectorUISyntaxBody @@ -25,8 +27,8 @@ struct NetworkDetailViewControllerTests { } @Test - func listShowsSimpleEmptyStateWithoutRequests() { - let model = NetworkPanelModel(context: makeContext()) + func listShowsSimpleEmptyStateWithoutRequests() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) let viewController = NetworkListViewController(model: model) let window = showInWindow(viewController) defer { window.isHidden = true } @@ -41,8 +43,8 @@ struct NetworkDetailViewControllerTests { } @Test - func detailShowsEmptyStateWithoutSelection() { - let model = NetworkPanelModel(context: makeContext()) + func detailShowsEmptyStateWithoutSelection() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) defer { window.isHidden = true } @@ -58,12 +60,12 @@ struct NetworkDetailViewControllerTests { } @Test - func detailCanDisableBackgroundDrawing() { + func detailCanDisableBackgroundDrawing() async throws { guard #available(iOS 26.0, *) else { return } - let model = NetworkPanelModel(context: makeContext()) + let model = try await NetworkPanelModel.make(context: makeContext()) let viewController = makeNetworkDetailViewController(model: model) viewController.traitOverrides.webInspectorDrawsBackground = false @@ -91,12 +93,12 @@ struct NetworkDetailViewControllerTests { } @Test - func listCanDisableBackgroundDrawing() { + func listCanDisableBackgroundDrawing() async throws { guard #available(iOS 26.0, *) else { return } - let model = NetworkPanelModel(context: makeContext()) + let model = try await NetworkPanelModel.make(context: makeContext()) let viewController = NetworkListViewController(model: model) viewController.traitOverrides.webInspectorDrawsBackground = false @@ -106,9 +108,10 @@ struct NetworkDetailViewControllerTests { } @Test - func listLoadDefersFilterMenuBuildUntilPresentation() throws { - let model = NetworkPanelModel(context: makeContext()) + func listLoadDefersFilterMenuBuildUntilPresentation() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() let viewController = NetworkListViewController(model: model) viewController.loadViewIfNeeded() @@ -133,7 +136,7 @@ struct NetworkDetailViewControllerTests { responseHeaders: ["content-type": "application/json"], responseMimeType: "application/json" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let viewController = NetworkListViewController(model: model) viewController.loadViewIfNeeded() @@ -151,8 +154,8 @@ struct NetworkDetailViewControllerTests { } @Test - func regularSplitKeepsPrimarySecondaryLayout() throws { - let model = NetworkPanelModel(context: makeContext()) + func regularSplitKeepsPrimarySecondaryLayout() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) let listViewController = NetworkListViewController(model: model) let detailViewController = makeNetworkDetailViewController(model: model) let splitViewController = NetworkSplitViewController( @@ -180,8 +183,8 @@ struct NetworkDetailViewControllerTests { } @Test - func detailContentKeepsPreviewRoleControlInSafeArea() { - let model = NetworkPanelModel(context: makeContext()) + func detailContentKeepsPreviewRoleControlInSafeArea() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) let viewController = makeNetworkDetailViewController(model: model) viewController.additionalSafeAreaInsets = UIEdgeInsets(top: 44, left: 120, bottom: 10, right: 24) let window = showInWindow(viewController) @@ -223,7 +226,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -282,7 +285,7 @@ struct NetworkDetailViewControllerTests { url: "https://example.com/no-body" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(bodyRequest) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -331,7 +334,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(responseOnlyRequest) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -378,7 +381,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: responseOnlyRequest, body: "response only body", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(requestAndResponse) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -430,7 +433,7 @@ struct NetworkDetailViewControllerTests { url: "https://example.com/no-body" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -465,7 +468,7 @@ struct NetworkDetailViewControllerTests { url: "https://example.com/api/data.json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -506,7 +509,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "text/plain" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -539,7 +542,7 @@ struct NetworkDetailViewControllerTests { postData: "name=Jane+Doe" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -550,7 +553,7 @@ struct NetworkDetailViewControllerTests { } #expect(didEnableMenu) - context.clearNetworkRequests() + await context.clearNetworkRequests() let didDisableMenu = await waitUntilRendered(in: viewController) { viewController.isDetailModeControlEnabledForTesting == false @@ -561,77 +564,95 @@ struct NetworkDetailViewControllerTests { @Test func responsePreviewRequestsRuntimeFetchWhenBodyIsAvailable() async throws { - let context = makeContext() - let request = try #require( - await applyRequest( - to: context, - requestID: "1", - url: "https://example.com/api/data.json", - responseHeaders: ["content-type": "application/json"], - responseMimeType: "application/json" + try await withLiveNetworkContext { fixture in + let request = try #require( + await applyRequest( + to: fixture.context, + requestID: "1", + url: "https://example.com/api/data.json", + responseHeaders: ["content-type": "application/json"], + responseMimeType: "application/json" + ) ) - ) - let model = NetworkPanelModel(context: context) - model.selectRequest(request) - let viewController = makeNetworkDetailViewController(model: model) - let window = showInWindow(viewController) - defer { window.isHidden = true } - viewController.setModeForTesting(.preview) + let model = try await NetworkPanelModel.make(context: fixture.context) + model.selectRequest(request) + let viewController = makeNetworkDetailViewController(model: model) + let window = showInWindow(viewController) + defer { window.isHidden = true } + await fixture.wire.fail( + "Network.getResponseBody", + message: "Intentional response-body failure." + ) + viewController.setModeForTesting(.preview) - let didFetch = await waitUntilRendered(in: viewController) { - guard case .failed = request.responseBody.phase else { - return false + let didFetch = await waitUntilRendered(in: viewController) { + guard case .failed = request.responseBody.phase else { + return false + } + return viewController.currentModeForTesting == .preview + && viewController.currentPreviewRoleForTesting == .response } - return viewController.currentModeForTesting == .preview - && viewController.currentPreviewRoleForTesting == .response + #expect(didFetch) + #expect(fixture.wire.observations.commands.filter { + $0.method == "Network.getResponseBody" + }.count == 1) } - #expect(didFetch) } @Test func hiddenDetailDoesNotFetchResponseBodyUntilAppearingAgain() async throws { - let context = makeContext() - let request = try #require( - await applyRequest( - to: context, - requestID: "1", - url: "https://example.com/api/data.json", - responseHeaders: ["content-type": "application/json"], - responseMimeType: "application/json" + try await withLiveNetworkContext { fixture in + let request = try #require( + await applyRequest( + to: fixture.context, + requestID: "1", + url: "https://example.com/api/data.json", + responseHeaders: ["content-type": "application/json"], + responseMimeType: "application/json" + ) + ) + let model = try await NetworkPanelModel.make(context: fixture.context) + model.selectRequest(request) + let viewController = makeNetworkDetailViewController(model: model) + let window = showInWindow(viewController) + defer { window.isHidden = true } + viewController.setModeForTesting(.headers) + + let didRenderHeaders = await waitUntilRendered(in: viewController) { + viewController.currentModeForTesting == .headers + && viewController.headersTextViewForTesting.renderedTextForTesting.contains("content-type: application/json") + } + #expect(didRenderHeaders) + #expect(request.responseBody.phase == .available) + + viewController.beginAppearanceTransition(false, animated: false) + viewController.endAppearanceTransition() + viewController.setModeForTesting(.preview) + + #expect(request.responseBody.phase == .available) + #expect(fixture.wire.observations.commands.contains { + $0.method == "Network.getResponseBody" + } == false) + await fixture.wire.fail( + "Network.getResponseBody", + message: "Intentional response-body failure." ) - ) - let model = NetworkPanelModel(context: context) - model.selectRequest(request) - let viewController = makeNetworkDetailViewController(model: model) - let window = showInWindow(viewController) - defer { window.isHidden = true } - viewController.setModeForTesting(.headers) - - let didRenderHeaders = await waitUntilRendered(in: viewController) { - viewController.currentModeForTesting == .headers - && viewController.headersTextViewForTesting.renderedTextForTesting.contains("content-type: application/json") - } - #expect(didRenderHeaders) - #expect(request.responseBody.phase == .available) - - viewController.beginAppearanceTransition(false, animated: false) - viewController.endAppearanceTransition() - viewController.setModeForTesting(.preview) - - #expect(request.responseBody.phase == .available) - #expect(viewController.headersTextViewForTesting.renderedTextForTesting.contains("content-type: application/json")) - viewController.beginAppearanceTransition(true, animated: false) - viewController.endAppearanceTransition() + viewController.beginAppearanceTransition(true, animated: false) + viewController.endAppearanceTransition() - let didFetchOnReturn = await waitUntilRendered(in: viewController) { - guard case .failed = request.responseBody.phase else { - return false + let didFetchOnReturn = await waitUntilRendered(in: viewController) { + guard case .failed = request.responseBody.phase else { + return false + } + return viewController.currentModeForTesting == .preview + && viewController.currentPreviewRoleForTesting == .response } - return viewController.currentModeForTesting == .preview - && viewController.currentPreviewRoleForTesting == .response + #expect(didFetchOnReturn) + #expect(fixture.wire.observations.commands.filter { + $0.method == "Network.getResponseBody" + }.count == 1) } - #expect(didFetchOnReturn) } @Test @@ -647,7 +668,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: #"{"visible":true}"#, base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model, initialMode: .preview) let window = showInWindow(viewController) @@ -688,7 +709,7 @@ struct NetworkDetailViewControllerTests { ) let bodyText = String(repeating: "[", count: 160) + "0" + String(repeating: "]", count: 160) applyResponseBody(to: context, request: request, body: bodyText, base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model, initialMode: .preview) let window = showInWindow(viewController) @@ -718,7 +739,7 @@ struct NetworkDetailViewControllerTests { ) let bodyText = "{\r\n\"a\":1,\r\n\"b\":[true]\r\n}" applyResponseBody(to: context, request: request, body: bodyText, base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model, initialMode: .preview) let window = showInWindow(viewController) @@ -842,7 +863,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let playerFactory = MoviePreviewPlayerFactorySpy() @@ -898,7 +919,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let playerFactory = MoviePreviewPlayerFactorySpy() @@ -947,7 +968,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let playerFactory = MoviePreviewPlayerFactorySpy() @@ -998,7 +1019,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let playerFactory = MoviePreviewPlayerFactorySpy() @@ -1043,7 +1064,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let playerFactory = MoviePreviewPlayerFactorySpy() @@ -1098,7 +1119,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: pngBase64String(size: imageSize), base64Encoded: true) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) viewController.syntaxBodyViewControllerForTesting.additionalSafeAreaInsets = UIEdgeInsets( @@ -1155,7 +1176,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: pngBase64String(size: imageSize), base64Encoded: true) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1202,7 +1223,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: pngBase64String(size: imageSize), base64Encoded: true) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1224,79 +1245,98 @@ struct NetworkDetailViewControllerTests { @Test func responsePreviewWaitsForLoadingFinishedBeforeFetching() async throws { - let context = makeContext() - let request = try #require( - await applyRequest( - to: context, - requestID: "1", - url: "https://example.com/api/data.json", - responseHeaders: ["content-type": "application/json"], - responseMimeType: "application/json", - finishes: false + try await withLiveNetworkContext { fixture in + let request = try #require( + await applyRequest( + to: fixture.context, + requestID: "1", + url: "https://example.com/api/data.json", + responseHeaders: ["content-type": "application/json"], + responseMimeType: "application/json", + finishes: false + ) ) - ) - let model = NetworkPanelModel(context: context) - model.selectRequest(request) - let viewController = makeNetworkDetailViewController(model: model) - let window = showInWindow(viewController) - defer { window.isHidden = true } - viewController.setModeForTesting(.preview) - - #expect(request.responseBody.phase == .available) - - await applyLoadingFinished(to: context, requestID: "1", timestamp: 3) + let model = try await NetworkPanelModel.make(context: fixture.context) + model.selectRequest(request) + let viewController = makeNetworkDetailViewController(model: model) + let window = showInWindow(viewController) + defer { window.isHidden = true } + viewController.setModeForTesting(.preview) + + #expect(request.responseBody.phase == .available) + #expect(fixture.wire.observations.commands.contains { + $0.method == "Network.getResponseBody" + } == false) + + await fixture.wire.fail( + "Network.getResponseBody", + message: "Intentional response-body failure." + ) + await applyLoadingFinished(to: fixture.context, requestID: "1", timestamp: 3) - let didFetch = await waitUntilRendered(in: viewController) { - guard case .failed = request.responseBody.phase else { - return false + let didFetch = await waitUntilRendered(in: viewController) { + guard case .failed = request.responseBody.phase else { + return false + } + return true } - return true + #expect(didFetch) + #expect(fixture.wire.observations.commands.filter { + $0.method == "Network.getResponseBody" + }.count == 1) } - #expect(didFetch) } @Test func failedResponseBodyDoesNotRefetchFromRendering() async throws { - let context = makeContext() - let request = try #require( - await applyRequest( - to: context, - requestID: "1", - url: "https://example.com/api/data.json", - responseHeaders: ["content-type": "application/json"], - responseMimeType: "application/json" + try await withLiveNetworkContext { fixture in + let request = try #require( + await applyRequest( + to: fixture.context, + requestID: "1", + url: "https://example.com/api/data.json", + responseHeaders: ["content-type": "application/json"], + responseMimeType: "application/json" + ) ) - ) - let model = NetworkPanelModel(context: context) - model.fetchResponseBodyIfNeeded(for: request) - let didFailInitialFetch = await waitForNetworkBodyPhase(in: request.responseBody) { phase in - if case .failed = phase { - return true + let model = try await NetworkPanelModel.make(context: fixture.context) + await fixture.wire.fail( + "Network.getResponseBody", + message: "Intentional response-body failure." + ) + model.fetchResponseBodyIfNeeded(for: request) + let didFailInitialFetch = await waitForNetworkBodyPhase(in: request.responseBody) { phase in + if case .failed = phase { + return true + } + return false + } != nil + #expect(didFailInitialFetch) + + model.selectRequest(request) + let viewController = makeNetworkDetailViewController(model: model) + let window = showInWindow(viewController) + defer { window.isHidden = true } + viewController.setModeForTesting(.preview) + + let didRenderFailure = await waitUntilRendered(in: viewController) { + viewController.currentModeForTesting == .preview + && viewController.currentPreviewRoleForTesting == .response + && viewController.syntaxBodyViewControllerForTesting.syntaxViewForTesting.text.isEmpty == false } - return false - } != nil - #expect(didFailInitialFetch) - - model.selectRequest(request) - let viewController = makeNetworkDetailViewController(model: model) - let window = showInWindow(viewController) - defer { window.isHidden = true } - viewController.setModeForTesting(.preview) - - let didRenderFailure = await waitUntilRendered(in: viewController) { - viewController.currentModeForTesting == .preview - && viewController.currentPreviewRoleForTesting == .response - && viewController.syntaxBodyViewControllerForTesting.syntaxViewForTesting.text.isEmpty == false - } - #expect(didRenderFailure) - let failedPhase = request.responseBody.phase + #expect(didRenderFailure) + let failedPhase = request.responseBody.phase - model.fetchResponseBodyIfNeeded(for: request) + model.fetchResponseBodyIfNeeded(for: request) - let didStayIdle = await waitUntilRendered(in: viewController) { - request.responseBody.phase == failedPhase + let didStayIdle = await waitUntilRendered(in: viewController) { + request.responseBody.phase == failedPhase + } + #expect(didStayIdle) + #expect(fixture.wire.observations.commands.filter { + $0.method == "Network.getResponseBody" + }.count == 1) } - #expect(didStayIdle) } @Test @@ -1311,7 +1351,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1339,7 +1379,7 @@ struct NetworkDetailViewControllerTests { finishes: false ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1380,7 +1420,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1430,7 +1470,7 @@ struct NetworkDetailViewControllerTests { finishes: false ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) let bodyPreview = RecordingNetworkBodyPreviewViewController() let viewController = makeNetworkDetailViewController( @@ -1489,7 +1529,7 @@ struct NetworkDetailViewControllerTests { responseMimeType: "application/json" ) ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(firstRequest) let viewController = makeNetworkDetailViewController(model: model) let window = showInWindow(viewController) @@ -1594,7 +1634,7 @@ struct NetworkDetailViewControllerTests { func compactContainerPushesAndPopsDetailFromSelection() async throws { let context = makeContext() let request = try #require(await applyRequest(to: context, requestID: "1", url: "https://example.com/app.js")) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let detailViewController = makeNetworkDetailViewController(model: model) let navigationController = NetworkCompactNavigationController( @@ -1625,7 +1665,7 @@ struct NetworkDetailViewControllerTests { func compactContainerCanPushSameRequestAfterBackNavigation() async throws { let context = makeContext() _ = try #require(await applyRequest(to: context, requestID: "1", url: "https://example.com/app.js")) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let detailViewController = makeNetworkDetailViewController(model: model) let navigationController = NetworkCompactNavigationController( @@ -1677,7 +1717,7 @@ struct NetworkDetailViewControllerTests { ) ) applyResponseBody(to: context, request: request, body: "not a real movie", base64Encoded: false) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let detailViewController = makeNetworkDetailViewController(model: model) detailViewController.setModeForTesting(.preview) @@ -1725,7 +1765,7 @@ struct NetworkDetailViewControllerTests { func compactContainerPopsDetailWhenSelectedRequestDisappears() async throws { let context = makeContext() let request = try #require(await applyRequest(to: context, requestID: "1", url: "https://example.com/app.js")) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let detailViewController = makeNetworkDetailViewController(model: model) let navigationController = NetworkCompactNavigationController( @@ -1743,8 +1783,8 @@ struct NetworkDetailViewControllerTests { #expect(didPush) await waitForNavigationTransitionToFinish(in: navigationController) - withUIKitAnimationsDisabled { - context.clearNetworkRequests() + await withUIKitAnimationsDisabled { + await context.clearNetworkRequests() } #expect(model.selectedRequestID == request.id) #expect(model.selectedRequest == nil) @@ -1756,14 +1796,14 @@ struct NetworkDetailViewControllerTests { } @Test - func visibleListAppliesLiveInsertThroughFetchedResultsTransactions() async throws { + func visibleListAppliesLiveInsertThroughFetchedResultsUpdates() async throws { let context = makeContext() let firstRequest = try #require(await applyRequest( to: context, requestID: "1", url: "https://example.com/first.js" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let window = showInWindow(listViewController, makeVisible: true) defer { window.isHidden = true } @@ -1773,7 +1813,7 @@ struct NetworkDetailViewControllerTests { let evaluationCountBeforeInsert = listViewController.displayRequestIDsEvaluationCountForTesting let snapshotApplyCountBeforeInsert = listViewController.snapshotApplyCountForTesting - let transactionDeliveryCountBeforeInsert = listViewController.fetchedResultsTransactionDeliveryCountForTesting + let updateDeliveryCountBeforeInsert = listViewController.fetchedResultsUpdateDeliveryCountForTesting let secondRequest = try #require(await applyRequest( to: context, requestID: "2", @@ -1783,7 +1823,7 @@ struct NetworkDetailViewControllerTests { let didRenderInsert = await waitUntilListShows( [secondRequest.id, firstRequest.id], in: listViewController, - afterTransactionDeliveryCount: transactionDeliveryCountBeforeInsert + afterUpdateDeliveryCount: updateDeliveryCountBeforeInsert ) #expect(didRenderInsert) #expect(listViewController.displayRequestIDsEvaluationCountForTesting == evaluationCountBeforeInsert) @@ -1791,7 +1831,7 @@ struct NetworkDetailViewControllerTests { } @Test - func visibleListAppliesDescriptorResetThroughFetchedResultsTransactions() async throws { + func visibleListAppliesDescriptorResetThroughFetchedResultsUpdates() async throws { let context = makeContext() _ = try #require(await applyRequest( to: context, @@ -1800,8 +1840,9 @@ struct NetworkDetailViewControllerTests { responseHeaders: ["content-type": "video/mp4"], responseMimeType: "video/mp4" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() let listViewController = NetworkListViewController(model: model) let window = showInWindow(listViewController, makeVisible: true) defer { window.isHidden = true } @@ -1811,13 +1852,13 @@ struct NetworkDetailViewControllerTests { let evaluationCountBeforeUpdate = listViewController.displayRequestIDsEvaluationCountForTesting let snapshotApplyCountBeforeUpdate = listViewController.snapshotApplyCountForTesting - let transactionDeliveryCountBeforeUpdate = listViewController.fetchedResultsTransactionDeliveryCountForTesting + let updateDeliveryCountBeforeUpdate = listViewController.fetchedResultsUpdateDeliveryCountForTesting model.setSearchText("does-not-match") let didRenderReset = await waitUntilListShows( [], in: listViewController, - afterTransactionDeliveryCount: transactionDeliveryCountBeforeUpdate + afterUpdateDeliveryCount: updateDeliveryCountBeforeUpdate ) #expect(didRenderReset) @@ -1837,7 +1878,7 @@ struct NetworkDetailViewControllerTests { responseHeaders: ["content-type": "video/mp4"], responseMimeType: "video/mp4" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let window = showInWindow(listViewController) defer { window.isHidden = true } @@ -1845,13 +1886,13 @@ struct NetworkDetailViewControllerTests { #expect(listViewController.displayedRequestIDsForTesting.count == 1) let evaluationCountBeforeHiddenUpdate = listViewController.displayRequestIDsEvaluationCountForTesting - let transactionDeliveryCountBeforeHiddenUpdate = listViewController - .fetchedResultsTransactionDeliveryCountForTesting + let updateDeliveryCountBeforeHiddenUpdate = listViewController + .fetchedResultsUpdateDeliveryCountForTesting listViewController.suspendRenderingForTesting() model.setSearchText("does-not-match") - #expect(await listViewController.waitForFetchedResultsTransactionDeliveryForTesting( - after: transactionDeliveryCountBeforeHiddenUpdate + #expect(await listViewController.waitForFetchedResultsUpdateDeliveryForTesting( + after: updateDeliveryCountBeforeHiddenUpdate )) #expect(listViewController.displayRequestIDsEvaluationCountForTesting == evaluationCountBeforeHiddenUpdate) @@ -1873,7 +1914,7 @@ struct NetworkDetailViewControllerTests { responseHeaders: ["content-type": "video/mp4"], responseMimeType: "video/mp4" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let listViewController = NetworkListViewController(model: model) let window = showInWindow(listViewController) defer { window.isHidden = true } @@ -1881,6 +1922,8 @@ struct NetworkDetailViewControllerTests { #expect(listViewController.displayedRequestIDsForTesting == [request.id]) let evaluationCountBeforeHiddenUpdate = listViewController.displayRequestIDsEvaluationCountForTesting + let updateDeliveryCountBeforeHiddenUpdate = listViewController + .fetchedResultsUpdateDeliveryCountForTesting listViewController.beginSnapshotApplyForTesting(requestIDs: [request.id]) listViewController.queueSnapshotUpdateForTesting(requestIDs: []) #expect(listViewController.hasPendingSnapshotUpdateForTesting) @@ -1889,6 +1932,9 @@ struct NetworkDetailViewControllerTests { #expect(listViewController.hasPendingSnapshotUpdateForTesting == false) model.setSearchText("does-not-match") + #expect(await listViewController.waitForFetchedResultsUpdateDeliveryForTesting( + after: updateDeliveryCountBeforeHiddenUpdate + )) listViewController.finishSnapshotApplyForTesting(requestIDs: [request.id]) await listViewController.flushPendingSnapshotUpdateForTesting() @@ -1911,8 +1957,9 @@ struct NetworkDetailViewControllerTests { responseHeaders: ["content-type": "video/mp4"], responseMimeType: "video/mp4" )) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() let listViewController = NetworkListViewController(model: model) listViewController.loadViewIfNeeded() listViewController.resumeRenderingForTesting() @@ -1985,8 +2032,8 @@ struct NetworkDetailViewControllerTests { } @Test - func listControllerDeallocatesWhileFetchedResultsTransactionTaskIsActive() async throws { - let model = NetworkPanelModel(context: makeContext()) + func listControllerDeallocatesWhileFetchedResultsUpdateTaskIsActive() async throws { + let model = try await NetworkPanelModel.make(context: makeContext()) let deinitProbe = UITestDeinitProbe() weak var weakViewController: NetworkListViewController? @@ -2004,12 +2051,55 @@ struct NetworkDetailViewControllerTests { #expect(weakViewController == nil) } - private func makeContext() -> WebInspectorContext { - WebInspectorContext.preview(isolation: MainActor.shared) + private func makeContext() -> WebInspectorModelContext { + WebInspectorModelContext.preview() + } + + private struct LiveNetworkContextFixture { + let runtime: WebInspectorProxyTestRuntime + let wire: WebInspectorRawWireDriver + let context: WebInspectorModelContext + } + + private func withLiveNetworkContext( + _ operation: @MainActor (LiveNetworkContextFixture) async throws -> Output + ) async throws -> Output { + let runtime = try await WebInspectorProxyTestRuntime.start() + let wire = WebInspectorRawWireDriver(peer: runtime.peer) + await wire.start() + await wire.respond(to: "Network.enable") + let context = WebInspectorModelContext( + configuration: .init(domains: [.network]) + ) + do { + try await context.attach(to: runtime.proxy, isolation: MainActor.shared) + } catch { + await runtime.close() + await wire.stop() + throw error + } + + let fixture = LiveNetworkContextFixture( + runtime: runtime, + wire: wire, + context: context + ) + let result: Result + do { + result = .success(try await operation(fixture)) + } catch { + result = .failure(error) + } + + await wire.respond(to: "Network.disable") + await context.close() + await runtime.close() + await wire.stop() + return try result.get() } private func applyRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, requestHeaders: [String: String] = [:], @@ -2064,7 +2154,7 @@ struct NetworkDetailViewControllerTests { } private func applyRequestWithoutResponse( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, requestHeaders: [String: String] = [:], @@ -2090,7 +2180,7 @@ struct NetworkDetailViewControllerTests { } private func applyResponseReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, responseHeaders: [String: String], @@ -2116,7 +2206,7 @@ struct NetworkDetailViewControllerTests { } private func applyDataReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, dataLength: Int, encodedDataLength: Int, @@ -2133,7 +2223,7 @@ struct NetworkDetailViewControllerTests { } private func applyLoadingFinished( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, timestamp: Double ) async { @@ -2148,7 +2238,7 @@ struct NetworkDetailViewControllerTests { } private func applyResponseBody( - to context: WebInspectorContext, + to context: WebInspectorModelContext, request: NetworkRequest, body: String, base64Encoded: Bool = false @@ -2271,10 +2361,10 @@ struct NetworkDetailViewControllerTests { private func waitUntilListShows( _ requestIDs: [NetworkRequest.ID], in viewController: NetworkListViewController, - afterTransactionDeliveryCount transactionDeliveryCount: Int + afterUpdateDeliveryCount updateDeliveryCount: Int ) async -> Bool { - guard await viewController.waitForFetchedResultsTransactionDeliveryForTesting( - after: transactionDeliveryCount + guard await viewController.waitForFetchedResultsUpdateDeliveryForTesting( + after: updateDeliveryCount ) else { return false } @@ -2363,6 +2453,13 @@ struct NetworkDetailViewControllerTests { return body() } + private func withUIKitAnimationsDisabled(_ body: () async -> T) async -> T { + let wereAnimationsEnabled = UIView.areAnimationsEnabled + UIView.setAnimationsEnabled(false) + defer { UIView.setAnimationsEnabled(wereAnimationsEnabled) } + return await body() + } + private func localizedResourceString(_ key: String, locale: String) -> String? { guard let bundleURL = WebInspectorUILocalization.bundle.url(forResource: locale, withExtension: "lproj"), let bundle = Bundle(url: bundleURL) else { diff --git a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift index 64725568..150d833d 100644 --- a/Tests/WebInspectorUITests/NetworkPanelModelTests.swift +++ b/Tests/WebInspectorUITests/NetworkPanelModelTests.swift @@ -39,13 +39,106 @@ func displayRequestsApplySearchFilterAndNewestFirstOrder() async throws { timestamp: 3 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("cdn") model.setResourceFilter(.script, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequests.map(\.id) == [scriptID]) } +@Test +@MainActor +func rapidCriteriaChangesPublishOnlyTheLatestConcreteQuery() async throws { + let context = makeContext() + let scriptID = await applyRequest( + to: context, + requestID: "1", + url: "https://cdn.example.com/app.js", + resourceType: .script, + mimeType: "text/javascript", + timestamp: 1 + ) + await applyRequest( + to: context, + requestID: "2", + url: "https://cdn.example.com/photo.png", + resourceType: .image, + mimeType: "image/png", + timestamp: 2 + ) + let model = try await NetworkPanelModel.make(context: context) + + model.setSearchText("cdn") + model.setResourceFilter(.script, enabled: true) + model.setSearchText("app.js") + await model.waitForQueryUpdates() + + #expect( + model.query == NetworkQuery( + search: "app.js", + resourceCategories: [.script], + sort: .requestTimeDescending + ) + ) + #expect(model.appliedQueryRevision == model.queryRevision) + #expect(model.displayRequestIDs == [scriptID]) +} + +@Test +@MainActor +func queryScheduledAfterClearWaitsForClearAndRemainsActive() async throws { + let context = makeContext() + let clearedRequestID = await applyRequest( + to: context, + requestID: "1", + url: "https://example.com/old-endpoint", + resourceType: .xhr, + mimeType: "application/json", + timestamp: 1 + ) + let model = try await NetworkPanelModel.make(context: context) + + model.setSearchText("old-endpoint") + model.clearRequests() + model.setSearchText("new-endpoint") + await model.waitForQueryUpdates() + + #expect(try context.networkRequest(id: clearedRequestID) == nil) + #expect(model.query.search == "new-endpoint") + #expect(model.appliedQueryRevision == model.queryRevision) + #expect(model.displayRequestIDs.isEmpty) + + let newRequestID = await applyRequest( + to: context, + requestID: "2", + url: "https://example.com/new-endpoint", + resourceType: .xhr, + mimeType: "application/json", + timestamp: 2 + ) + + #expect(model.displayRequestIDs == [newRequestID]) +} + +@Test +@MainActor +func retireCancelsAndAwaitsOwnedQueryWork() async throws { + let context = makeContext() + var model: NetworkPanelModel? = try await NetworkPanelModel.make(context: context) + weak let retainedModel = model + + model?.setSearchText("first") + model?.setSearchText("latest") + await model?.retire() + + #expect(model?.isRetiredForTesting == true) + + model = nil + + #expect(retainedModel == nil) +} + @Test @MainActor func clearAvailabilityUsesUnfilteredRequestsWhenFiltersHideEveryRequest() async throws { @@ -58,7 +151,7 @@ func clearAvailabilityUsesUnfilteredRequestsWhenFiltersHideEveryRequest() async mimeType: "text/javascript", timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) let observation = withPortableContinuousObservation { _ in _ = model.hasClearableRequests } @@ -74,16 +167,18 @@ func clearAvailabilityUsesUnfilteredRequestsWhenFiltersHideEveryRequest() async #expect(observedValues.latestValue == true) model.setSearchText("does-not-match") + await model.waitForQueryUpdates() #expect(model.isEmpty) #expect(model.displayRequestIDs.isEmpty) #expect(model.hasClearableRequests) model.clearRequests() + await model.waitForQueryUpdates() #expect(model.hasClearableRequests == false) #expect(await observedValues.waitUntilValue(false)) - #expect(context.registeredRequest(for: requestID) == nil) + #expect(try context.networkRequest(id: requestID) == nil) } @Test @@ -98,11 +193,12 @@ func selectedRequestUsesUnfilteredContextWhenFiltersHideRequest() async throws { mimeType: "text/javascript", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) + let request = try #require(try context.networkRequest(id: requestID)) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) model.setSearchText("does-not-match") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.isEmpty) #expect(model.selectedRequestID == requestID) @@ -121,10 +217,11 @@ func selectedRequestInvalidatesWhenUnfilteredRequestDisappears() async throws { mimeType: "text/javascript", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) + let request = try #require(try context.networkRequest(id: requestID)) + let model = try await NetworkPanelModel.make(context: context) model.selectRequest(request) model.setSearchText("does-not-match") + await model.waitForQueryUpdates() let observation = withPortableContinuousObservation { _ in _ = model.selectedRequest } @@ -139,7 +236,7 @@ func selectedRequestInvalidatesWhenUnfilteredRequestDisappears() async throws { #expect(model.displayRequestIDs.isEmpty) #expect(model.selectedRequest === request) - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(model.selectedRequestID == requestID) #expect(model.selectedRequest == nil) @@ -173,7 +270,7 @@ func requestDisplayUsesReadableURLDisplayName(url: String, expectedDisplayName: mimeType: "text/html", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.displayName == expectedDisplayName) } @@ -198,9 +295,9 @@ func requestDisplayUsesEncodingFallbackForURLDerivedLabelsAndFilters() async thr mimeType: nil, timestamp: 2 ) - let model = NetworkPanelModel(context: context) - let spacedURLRequest = try #require(context.registeredRequest(for: spacedURLRequestID)) - let invalidEscapeRequest = try #require(context.registeredRequest(for: invalidEscapeRequestID)) + let model = try await NetworkPanelModel.make(context: context) + let spacedURLRequest = try #require(try context.networkRequest(id: spacedURLRequestID)) + let invalidEscapeRequest = try #require(try context.networkRequest(id: invalidEscapeRequestID)) #expect(spacedURLRequest.displayName == "photo 1.png") #expect(spacedURLRequest.fileTypeLabel == "png") @@ -208,6 +305,7 @@ func requestDisplayUsesEncodingFallbackForURLDerivedLabelsAndFilters() async thr #expect(invalidEscapeRequest.fileTypeLabel == "png") model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [invalidEscapeRequestID, spacedURLRequestID]) } @@ -368,8 +466,9 @@ func mediaFilterIncludesPreviewableMediaResponses() async throws { timestamp: 19 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequests.map(\.id) == [ headerMediaID, @@ -451,8 +550,9 @@ func displayResourceFilterUpdatesWhenResponseMIMEBecomesPreviewable() async thro resourceType: .xhr, timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.isEmpty) @@ -465,7 +565,7 @@ func displayResourceFilterUpdatesWhenResponseMIMEBecomesPreviewable() async thro timestamp: 1.1 ) #expect(model.displayRequestIDs == [requestID]) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.displayResourceFilter(mediaPreviewClassifier: { mimeType, url in NetworkDisplay.MediaPreviewSupport.classification(mimeType: mimeType, url: url) }) == .media) @@ -485,7 +585,7 @@ func requestStatusSeverityUpdatesWhenResponseChanges() async throws { statusText: "Server Error", timestamp: 1 ) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.statusSeverity == .error) @@ -514,9 +614,10 @@ func displaySearchFieldsUpdateWhenRequestChanges() async throws { resourceType: .xhr, timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("new-endpoint") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.isEmpty) await applyRedirectRequest( @@ -530,9 +631,11 @@ func displaySearchFieldsUpdateWhenRequestChanges() async throws { ) #expect(model.displayRequestIDs == [requestID]) model.setSearchText("PATCH") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [requestID]) model.setSearchText("json") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.isEmpty) await applyResponseReceived( @@ -547,6 +650,7 @@ func displaySearchFieldsUpdateWhenRequestChanges() async throws { ) #expect(model.displayRequestIDs == [requestID]) model.setSearchText("Created") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [requestID]) } @@ -570,11 +674,12 @@ func requestFileTypeAndSearchUpdateWhenRawMIMETypeAppears() async throws { responseHeaders: ["Content-Type": "application/json"], timestamp: 1.1 ) - let model = NetworkPanelModel(context: context) - let request = try #require(context.registeredRequest(for: requestID)) + let model = try await NetworkPanelModel.make(context: context) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.fileTypeLabel == "xhr") model.setSearchText("json") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.isEmpty) await applyResponseReceived( @@ -602,9 +707,10 @@ func displayRequestsIgnoreContentOnlyUpdatesDuringActiveFiltering() async throws mimeType: "application/octet-stream", timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("clip") model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [requestID]) @@ -634,12 +740,14 @@ func displayRequestsUpdateWhenCriteriaChanges() async throws { mimeType: "image/png", timestamp: 2 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("cdn") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.count == 2) model.setResourceFilter(.script, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [scriptID]) } @@ -655,12 +763,13 @@ func displayRequestsClearAfterReset() async throws { mimeType: "text/javascript", timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("cdn") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs.count == 1) - context.clearNetworkRequests() + await context.clearNetworkRequests() #expect(model.displayRequestIDs.isEmpty) } @@ -685,8 +794,9 @@ func displayRequestsUpdateWhenResourceCategoryChanges() async throws { mimeType: "application/octet-stream", timestamp: 2 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [mediaID]) @@ -716,16 +826,18 @@ func displayRequestIDsUseDataKitClassificationForMediaFiltering() async throws { mimeType: "application/octet-stream", timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) #expect(model.displayRequestIDs == [requestID]) - let request = try #require(context.registeredRequest(for: requestID)) + let request = try #require(try context.networkRequest(id: requestID)) #expect(request.displayName == "clip.mp4") model.setSearchText("clip") + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [requestID]) model.setResourceFilter(.media, enabled: true) + await model.waitForQueryUpdates() #expect(model.displayRequestIDs == [requestID]) } @@ -741,57 +853,32 @@ func clearRequestsClearsSelectionButPreservesDisplayCriteria() async throws { mimeType: "text/javascript", timestamp: 1 ) - let model = NetworkPanelModel(context: context) + let model = try await NetworkPanelModel.make(context: context) model.setSearchText("cdn") model.setResourceFilter(.script, enabled: true) - model.selectRequest(context.registeredRequest(for: requestID)) + model.selectRequest(try context.networkRequest(id: requestID)) model.clearRequests() + await model.waitForQueryUpdates() #expect(model.selectedRequestID == nil) #expect(model.searchText == "cdn") #expect(model.activeResourceFilters == [.script]) #expect(model.displayRequests.isEmpty) - #expect(context.registeredRequest(for: requestID) == nil) + #expect(try context.networkRequest(id: requestID) == nil) } -@Test -@MainActor -func responseBodyFetchMovesUnavailablePreviewContextToFailedPhase() async throws { - let context = makeContext() - let requestID = await applyRequest( - to: context, - requestID: "1", - url: "https://api.example.com/data.json", - resourceType: .xhr, - mimeType: "application/json", - timestamp: 1 - ) - let request = try #require(context.registeredRequest(for: requestID)) - let model = NetworkPanelModel(context: context) - - #expect(request.canFetchResponseBody) - let expectedBody = request.responseBody - model.fetchResponseBodyIfNeeded(for: request) - - #expect(await waitForNetworkBodyPhase(in: expectedBody) { phase in - if case .failed = phase { - return true - } - return false - } != nil) -} } @MainActor -private func makeContext() -> WebInspectorContext { - WebInspectorContext.preview(isolation: MainActor.shared) +private func makeContext() -> WebInspectorModelContext { + WebInspectorModelContext.preview() } @MainActor @discardableResult private func applyRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, method: String = "GET", @@ -828,7 +915,7 @@ private func applyRequest( @MainActor @discardableResult private func applyPendingRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, method: String = "GET", @@ -850,7 +937,7 @@ private func applyPendingRequest( @MainActor private func applyRedirectRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, method: String, @@ -872,7 +959,7 @@ private func applyRedirectRequest( @MainActor private func applyResponseReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String, resourceType: Network.ResourceType, @@ -902,7 +989,7 @@ private func applyResponseReceived( @MainActor private func applyDataReceived( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, dataLength: Int, encodedDataLength: Int, @@ -920,7 +1007,7 @@ private func applyDataReceived( @MainActor private func applyLoadingFinished( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, timestamp: Double ) async { diff --git a/Tests/WebInspectorUITests/ParentContainerTests.swift b/Tests/WebInspectorUITests/ParentContainerTests.swift index d3d032a5..fb7bfe2f 100644 --- a/Tests/WebInspectorUITests/ParentContainerTests.swift +++ b/Tests/WebInspectorUITests/ParentContainerTests.swift @@ -4,6 +4,7 @@ import Testing import WebInspectorDataKit import WebInspectorProxyKit import WebInspectorProxyKitTesting +import WebInspectorTestSupport import UIKit @testable import WebInspectorUI @testable import WebInspectorUISyntaxBody @@ -17,6 +18,12 @@ extension WebInspectorUIRenderingTests { struct ParentContainerTests { private struct AttachmentFailure: Error {} + private struct NetworkResourceFailure: LocalizedError { + var errorDescription: String? { + "Network bootstrap failed." + } + } + @Test func sessionAndViewControllerUseDOMAndNetworkTabsByDefault() { let session = WebInspectorSession() @@ -63,7 +70,7 @@ struct ParentContainerTests { @Test func sessionUpdatesPageUserInterfaceStyleFromPageObserver() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() try await attach( session, @@ -82,7 +89,7 @@ struct ParentContainerTests { @Test func sessionClearsPageUserInterfaceStyleAndStopsObservingOnDetach() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() try await attach( session, @@ -104,7 +111,7 @@ struct ParentContainerTests { @Test func sessionClearsPageUserInterfaceStyleAndStopsObservingWhenAttachFails() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() try await attach( session, @@ -138,157 +145,423 @@ struct ParentContainerTests { @Test func staleAttachCompletionDoesNotReplaceNewerAttach() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() + let stableModel = session.model let firstAttachStarted = WebInspectorTestGate() let firstAttachGate = WebInspectorTestGate() let firstAttach = Task { @MainActor in try await session.attachForTesting( - makeContainer: { [self] in - await firstAttachStarted.open() - await firstAttachGate.wait() - return try await makeFakeContainer() + makeProxy: { [self] in + firstAttachStarted.open() + await firstAttachGate.waiter.wait() + return try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) } - await firstAttachStarted.wait() + await firstAttachStarted.waiter.wait() - var secondContext: WebInspectorContext? try await session.attachForTesting( - makeContainer: { [self] in - let container = try await makeFakeContainer() - secondContext = container.mainContext - return container + makeProxy: { [self] in + try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) - let installedSecondContext = try #require(secondContext) - - await firstAttachGate.open() + firstAttachGate.open() do { try await firstAttach.value Issue.record("Expected superseded attach to be cancelled.") } catch is CancellationError { } - #expect(session.context === installedSecondContext) + #expect(session.model === stableModel) + #expect(session.model.state == .attached) #expect(observerRecorder.observers.count == 1) #expect(observerRecorder.observers.first?.isStarted == true) #expect(observerRecorder.observers.first?.isInvalidated == false) #expect(session.pageUserInterfaceStyle == .dark) } + @Test + func staleInstalledAttachDetachesWhenNewerAttachFailsBeforeInstallation() async throws { + let session = makeAttachmentSession() + let firstModelAttachCompleted = WebInspectorTestGate() + let releaseFirstAttach = WebInspectorTestGate() + let secondAttachStarted = WebInspectorTestGate() + let releaseSecondAttach = WebInspectorTestGate() + + let firstAttach = Task { @MainActor in + try await session.attachForTesting( + makeProxy: { [self] in + try await makeFakeProxy() + }, + afterModelAttach: { + firstModelAttachCompleted.open() + await releaseFirstAttach.waiter.wait() + } + ) + } + await firstModelAttachCompleted.waiter.wait() + #expect(session.model.state == .attached) + + let secondAttach = Task { @MainActor in + try await session.attachForTesting( + makeProxy: { + secondAttachStarted.open() + await releaseSecondAttach.waiter.wait() + throw AttachmentFailure() + } + ) + } + await secondAttachStarted.waiter.wait() + releaseFirstAttach.open() + + await #expect(throws: CancellationError.self) { + try await firstAttach.value + } + #expect(session.model.state == .detached) + + releaseSecondAttach.open() + await #expect(throws: AttachmentFailure.self) { + try await secondAttach.value + } + #expect(session.model.state == .detached) + } + @Test func detachInvalidatesInFlightAttachCompletion() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() + let stableModel = session.model let attachStarted = WebInspectorTestGate() let attachGate = WebInspectorTestGate() let attachTask = Task { @MainActor in try await session.attachForTesting( - makeContainer: { [self] in - await attachStarted.open() - await attachGate.wait() - return try await makeFakeContainer() + makeProxy: { [self] in + attachStarted.open() + await attachGate.waiter.wait() + return try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: observerRecorder.makeObserver ) } - await attachStarted.wait() + await attachStarted.waiter.wait() await session.detach() - let detachedContext = session.context - - await attachGate.open() + attachGate.open() do { try await attachTask.value Issue.record("Expected attach completion after detach to be cancelled.") } catch is CancellationError { } - #expect(session.context === detachedContext) - #expect(session.context.state == .detached) + #expect(session.model === stableModel) + #expect(session.model.state == .detached) #expect(observerRecorder.observers.isEmpty) #expect(session.hasPageUserInterfaceStyleObserverForTesting == false) #expect(session.pageUserInterfaceStyle == .unspecified) } @Test - func attachClearsCachedTabContentForPreviousContext() async throws { - let session = makeSessionWithNoOpAttachment() - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .dom, - session: session, - hostLayout: .compact + func attachPreservesStableModelAndRootPresentationContent() async throws { + let tab = makeNoOpTab(id: "webinspector_test_stable_attach") + let session = makeAttachmentSession(tabs: [tab]) + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting + let stableModel = session.model + let key = customContentKey(for: tab.id) + let initialHost = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(tab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - hostLayout: .regular + await contentStore.waitForCustomResourceTaskForTesting(for: key) + let initialContent = try #require( + contentStore.customReadyViewControllerForTesting(for: key) + ) + + try await attach(session) + + let hostAfterAttach = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(tab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) - #expect(session.interface.contentCacheCountForTesting > 0) - var installedContext: WebInspectorContext? - try await session.attachForTesting { - let container = try await makeFakeContainer() - installedContext = container.mainContext - return container + #expect(session.model === stableModel) + #expect(session.model.state == .attached) + #expect(hostAfterAttach !== initialHost) + #expect(hostAfterAttach.readyViewControllerForTesting === initialContent) + #expect(contentStore.customReadyViewControllerForTesting(for: key) === initialContent) + } + + @Test + func presentationContentStoreReusesEntriesUntilRootClear() async { + let contentStore = PresentationContentStore() + let key = WebInspectorTab.ContentKey(tabID: "cached-tab", contentID: "root") + + let first = contentStore.viewController(for: key) { UIViewController() } + #expect(contentStore.viewController(for: key) { UIViewController() } === first) + + await contentStore.clear() + + let second = contentStore.viewController(for: key) { UIViewController() } + #expect(second !== first) + #expect(contentStore.viewController(for: key) { UIViewController() } === second) + #expect(contentStore.contentCountForTesting == 1) + } + + @Test + func networkResourceTransitionsFromNativeLoadingToReadyInPlace() async throws { + let context = makeContext() + let factoryStarted = WebInspectorTestGate() + let factoryRelease = WebInspectorTestGate() + let contentStore = PresentationContentStore { context in + factoryStarted.open() + await factoryRelease.waiter.wait() + return try await NetworkPanelModel.make(context: context) + } + let observation = withPortableContinuousObservation { _ in + _ = contentStore.networkResourceRevision + } + let statuses = await observation.values { + contentStore.networkResourceStatus } + defer { + statuses.cancel() + observation.cancel() + } + + let resourceViewController = contentStore.networkViewController( + context: context + ) { _ in + UIViewController() + } + + #expect(resourceViewController.phase == .loading) + #expect(resourceViewController.readyViewControllerForTesting == nil) + #expect(resourceViewController.contentUnavailableConfiguration != nil) + #expect(contentStore.networkResourceStatus == .loading) + + await factoryStarted.waiter.wait() + factoryRelease.open() + await contentStore.waitForNetworkResourceTaskForTesting() - let context = try #require(installedContext) - #expect(session.context === context) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect(await statuses.waitUntilValue(.ready)) + #expect(resourceViewController.phase == .ready) + #expect(resourceViewController.readyViewControllerForTesting != nil) + #expect(resourceViewController.contentUnavailableConfiguration == nil) + + await contentStore.clear() } @Test - func installingDataContextClearsCachedTabContent() { - let session = makeSessionWithNoOpAttachment() - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .dom, - session: session, - hostLayout: .compact - ) - _ = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - hostLayout: .regular + func networkResourceWrappersAreHostOwnedWhileModelRetirementIsRootOwned() async throws { + let context = makeContext() + let contentStore = PresentationContentStore() + let firstResourceViewController = contentStore.networkViewController( + context: context + ) { _ in + UIViewController() + } + await contentStore.waitForNetworkResourceTaskForTesting() + let model = try #require(contentStore.networkPanelModelForTesting) + + let secondResourceViewController = contentStore.networkViewController( + context: context + ) { _ in + UIViewController() + } + + #expect(secondResourceViewController !== firstResourceViewController) + #expect(firstResourceViewController.phase == .ready) + #expect(secondResourceViewController.phase == .ready) + #expect(contentStore.networkPanelModelForTesting === model) + #expect(model.isRetiredForTesting == false) + + await contentStore.clear() + + #expect(model.isRetiredForTesting) + #expect(contentStore.networkResourceStatus == .idle) + #expect(firstResourceViewController.phase == .loading) + #expect(secondResourceViewController.phase == .loading) + #expect(firstResourceViewController.readyViewControllerForTesting == nil) + #expect(secondResourceViewController.readyViewControllerForTesting == nil) + } + + @Test + func rootClearRejectsLateNetworkModelBeforePublishingRestartedReadyState() async throws { + let context = makeContext() + let firstFactoryStarted = WebInspectorTestGate() + let firstFactoryRelease = WebInspectorTestGate() + let secondFactoryStarted = WebInspectorTestGate() + let secondFactoryRelease = WebInspectorTestGate() + var firstModel: NetworkPanelModel? + var factoryInvocationCount = 0 + let contentStore = PresentationContentStore { candidateContext in + #expect(candidateContext === context) + let model = try await NetworkPanelModel.make(context: candidateContext) + factoryInvocationCount += 1 + if factoryInvocationCount == 1 { + firstModel = model + firstFactoryStarted.open() + await firstFactoryRelease.waiter.wait() + } else { + #expect(factoryInvocationCount == 2) + secondFactoryStarted.open() + await secondFactoryRelease.waiter.wait() + } + return model + } + + let firstResourceViewController = contentStore.networkViewController( + context: context + ) { _ in + UIViewController() + } + await firstFactoryStarted.waiter.wait() + let withheldFirstModel = try #require(firstModel) + + let clearTask = Task { @MainActor in + await contentStore.clear() + } + for _ in 0..<100 where contentStore.networkResourceStatus != .idle { + await Task.yield() + } + + #expect(firstResourceViewController.phase == .loading) + #expect(contentStore.networkResourceStatus == .idle) + + firstFactoryRelease.open() + await clearTask.value + + #expect(withheldFirstModel.isRetiredForTesting) + #expect(contentStore.networkResourceStatus == .idle) + + let secondResourceViewController = contentStore.networkViewController( + context: context + ) { _ in + UIViewController() + } + await secondFactoryStarted.waiter.wait() + + #expect(secondResourceViewController.phase == .loading) + #expect(secondResourceViewController.readyViewControllerForTesting == nil) + + secondFactoryRelease.open() + await contentStore.waitForNetworkResourceTaskForTesting() + + let secondModel = try #require(contentStore.networkPanelModelForTesting) + #expect(secondModel.context === context) + #expect(secondResourceViewController.phase == .ready) + #expect(firstResourceViewController.phase == .loading) + + await contentStore.clear() + } + + @Test + func networkResourceFailureReplacesLoadingWithoutCreatingPlaceholderContent() async { + let context = makeContext() + let contentStore = PresentationContentStore { _ in + throw NetworkResourceFailure() + } + let resourceViewController = contentStore.networkViewController( + context: context + ) { _ in + Issue.record("A failed Network resource must not create ready content.") + return UIViewController() + } + + #expect(resourceViewController.phase == .loading) + #expect(resourceViewController.readyViewControllerForTesting == nil) + + await contentStore.waitForNetworkResourceTaskForTesting() + + #expect(contentStore.networkResourceStatus == .failed("Network bootstrap failed.")) + #expect(resourceViewController.phase == .failed("Network bootstrap failed.")) + #expect(resourceViewController.readyViewControllerForTesting == nil) + #expect( + (resourceViewController.contentUnavailableConfiguration as? UIContentUnavailableConfiguration)? + .secondaryText + == "Network bootstrap failed." ) - #expect(session.interface.contentCacheCountForTesting > 0) + await contentStore.clear() + } + + @Test + func networkResourceLoadDoesNotRetainStore() async throws { let context = makeContext() - session.installDataContext(context) + let factoryStarted = WebInspectorTestGate() + let factoryRelease = WebInspectorTestGate() + var contentStore: PresentationContentStore? = PresentationContentStore { context in + let model = try await NetworkPanelModel.make(context: context) + factoryStarted.open() + await factoryRelease.waiter.wait() + return model + } + weak let retainedStore = contentStore + let resourceViewController = try #require(contentStore).networkViewController( + context: context + ) { _ in + UIViewController() + } + await factoryStarted.waiter.wait() + + contentStore = nil + + #expect(retainedStore == nil) + #expect(resourceViewController.phase == .loading) - #expect(session.context === context) - #expect(session.interface.contentCacheCountForTesting == 0) + factoryRelease.open() + #expect(resourceViewController.readyViewControllerForTesting == nil) } @Test - func contentCacheEvictsEntriesFromPreviousEpoch() { - let cache = WebInspectorTab.ContentCache() - let key = WebInspectorTab.ContentKey(tabID: "epoch-tab", contentID: "root") + func networkResourceStoreDeinitSynchronouslyRetiresReadyBackstop() async throws { + let context = makeContext() + var contentStore: PresentationContentStore? = PresentationContentStore() + weak let retainedStore = contentStore + let resourceViewController = try #require(contentStore).networkViewController( + context: context + ) { _ in + UIViewController() + } + await contentStore?.waitForNetworkResourceTaskForTesting() + let model = try #require(contentStore?.networkPanelModelForTesting) - let first = cache.viewController(for: key, epoch: 0) { UIViewController() } - #expect(cache.viewController(for: key, epoch: 0) { UIViewController() } === first) + #expect(resourceViewController.phase == .ready) + #expect(model.isRetiredForTesting == false) - let second = cache.viewController(for: key, epoch: 1) { UIViewController() } - #expect(second !== first) - #expect(cache.viewController(for: key, epoch: 1) { UIViewController() } === second) - #expect(cache.countForTesting == 1) + contentStore = nil + + #expect(retainedStore == nil) + #expect(model.isRetiredForTesting) + #expect(resourceViewController.phase == .loading) + #expect(resourceViewController.readyViewControllerForTesting == nil) } @Test func representationBeforeDeferredRetirementKeepsContentAndSkipsDetach() async throws { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting _ = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) viewController.loadViewIfNeeded() - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting viewController.finishRootPresentationLifecycleForTesting() @@ -299,33 +572,30 @@ struct ParentContainerTests { #expect(await viewController.waitForRootPresentationRetirementTaskCompletionForTesting(after: retirementBaseline)) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.contentCountForTesting > 0) } @Test - func compactHostRebuildsActiveTabsWhenDataContextChanges() async throws { - let session = makeSessionWithNoOpAttachment() + func compactHostPreservesActiveTabsAcrossStableModelAttach() async throws { + let session = makeAttachmentSession() let viewController = WebInspectorViewController(session: session) viewController.horizontalSizeClassOverrideForTesting = .compact viewController.loadViewIfNeeded() let compactHost = try #require(viewController.activeHostViewControllerForTesting as? CompactTabBarController) let initialTabs = compactHost.currentUITabsForTesting let initialTabIdentities = initialTabs.map(ObjectIdentifier.init) + let stableModel = session.model #expect(initialTabs.isEmpty == false) - session.installDataContext(makeContext()) + try await attach(session) - let didRebuildTabs = await waitUntilCompactHostRendered(in: compactHost) { - let rebuiltTabs = compactHost.currentUITabsForTesting - return rebuiltTabs.count == initialTabs.count - && rebuiltTabs.map(ObjectIdentifier.init) != initialTabIdentities - } - #expect(didRebuildTabs) + #expect(session.model === stableModel) + #expect(compactHost.currentUITabsForTesting.map(ObjectIdentifier.init) == initialTabIdentities) } @Test - func regularHostRebuildsVisibleContentWhenDataContextChanges() async throws { - let session = makeSessionWithNoOpAttachment() + func regularHostPreservesVisibleContentAcrossStableModelAttach() async throws { + let session = makeAttachmentSession() let viewController = WebInspectorViewController(session: session) viewController.horizontalSizeClassOverrideForTesting = .regular viewController.loadViewIfNeeded() @@ -333,22 +603,18 @@ struct ParentContainerTests { viewController.activeHostViewControllerForTesting as? RegularTabContentViewController ) let initialRootViewController = try #require(regularHost.viewControllers.first) + let stableModel = session.model - session.installDataContext(makeContext()) + try await attach(session) - let didRebuildContent = await waitUntilRegularHostRendered(in: regularHost) { - guard let currentRootViewController = regularHost.viewControllers.first else { - return false - } - return currentRootViewController !== initialRootViewController - } - #expect(didRebuildContent) + #expect(session.model === stableModel) + #expect(regularHost.viewControllers.first === initialRootViewController) } @Test func viewControllerDoesNotApplyPageUserInterfaceStyle() async throws { let observerRecorder = PageUserInterfaceStyleObserverRecorder(styleOnStart: .dark) - let session = makeSessionWithNoOpAttachment() + let session = makeAttachmentSession() let viewController = WebInspectorViewController(session: session) let window = showInWindow(viewController) defer { window.isHidden = true } @@ -402,11 +668,11 @@ struct ParentContainerTests { } @Test - func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() throws { + func viewControllerPreviewSessionInjectsMockDOMAndNetworkModels() async throws { let session = WebInspectorViewControllerPreviewFixtures.makeSession() - let model = session.interface.networkPanelModel(for: session.context) + let model = try await NetworkPanelModel.make(context: session.model) - #expect(session.context.rootNode?.nodeName == "#document") + #expect(try session.model.rootDOMNode?.nodeName == "#document") #expect(model.displayRequests.count >= 2) } @@ -426,7 +692,7 @@ struct ParentContainerTests { } @Test - func customTabUsesPublicDescriptorAndCachedViewControllerFactory() throws { + func customTabUsesPublicDescriptorAndSharedAsyncResourceFactory() async throws { let customViewController = UIViewController() var factoryCallCount = 0 var factorySession: WebInspectorSession? @@ -440,7 +706,9 @@ struct ParentContainerTests { return customViewController } let session = WebInspectorSession(tabs: [.dom, customTab, .network]) + let contentStore = PresentationContentStore() let projection = WebInspectorTab.DisplayProjection() + let key = customContentKey(for: customTab.id) #expect( projection.displayItems(for: .compact, tabs: session.interface.tabs).map(\.id) @@ -466,38 +734,131 @@ struct ParentContainerTests { )?.title == "Console" ) - let compactContent = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(customTab.id), - session: session, - hostLayout: .compact, - tabs: session.interface.tabs + let compactHost = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) let regularContent = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(customTab.id), session: session, - hostLayout: .regular, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .regular ) regularContent.loadViewIfNeeded() + let regularHost = try #require( + regularContent.children.first as? CustomTabResourceViewController + ) + await contentStore.waitForCustomResourceTaskForTesting(for: key) #expect(regularContent !== customViewController) - #expect(customViewController.parent === regularContent) + #expect(compactHost.readyViewControllerForTesting == nil) + #expect(regularHost.readyViewControllerForTesting === customViewController) + #expect(customViewController.parent === regularHost) - let reparentedContent = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(customTab.id), - session: session, - hostLayout: .compact, - tabs: session.interface.tabs + let reparentedHost = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) - #expect(compactContent === customViewController) - #expect(reparentedContent === customViewController) - #expect(reparentedContent.parent == nil) + #expect(reparentedHost !== compactHost) + #expect(reparentedHost.readyViewControllerForTesting === customViewController) + #expect(customViewController.parent === reparentedHost) #expect(factorySession === session) #expect(factoryCallCount == 1) } @Test - func customTabDisplayItemDoesNotCollideWithInternalDOMElementIdentifier() throws { + func rootContentStoreDoesNotCloseCycleThroughSessionRetainingCustomController() async throws { + weak var retainedRoot: WebInspectorViewController? + weak var retainedStore: PresentationContentStore? + weak var retainedCache: WebInspectorTab.ContentCache? + weak var retainedSession: WebInspectorSession? + weak var retainedContent: UIViewController? + + do { + let customTab = WebInspectorTab( + id: "webinspector_custom_retaining_session", + title: "Retaining Session", + image: nil + ) { session in + SessionRetainingViewController(session: session) + } + let session = WebInspectorSession(tabs: [customTab]) + let root = WebInspectorViewController(session: session) + let contentStore = root.presentationContentStoreForTesting + let key = customContentKey(for: customTab.id) + let host = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) + await contentStore.waitForCustomResourceTaskForTesting(for: key) + let retainingContent = try #require( + contentStore.customReadyViewControllerForTesting(for: key) + as? SessionRetainingViewController + ) + + #expect(retainingContent.session === session) + retainedRoot = root + retainedStore = contentStore + retainedCache = contentStore.contentCacheForTesting + retainedSession = session + retainedContent = retainingContent + _ = host + } + autoreleasepool {} + + #expect(retainedRoot == nil) + #expect(retainedStore == nil) + #expect(retainedCache == nil) + #expect(retainedSession == nil) + #expect(retainedContent == nil) + } + + @Test + func presentationContentStoreDeinitDetachesExternallyRetainedCustomController() async throws { + let customViewController = UIViewController() + let customTab = WebInspectorTab( + id: "webinspector_custom_store_deinit", + title: "Store Deinit", + image: nil + ) { _ in + customViewController + } + let session = WebInspectorSession(tabs: [customTab]) + var contentStore: PresentationContentStore? = PresentationContentStore() + weak let retainedStore = contentStore + let key = customContentKey(for: customTab.id) + let regularContent = WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: try #require(contentStore), + hostLayout: .regular + ) + regularContent.loadViewIfNeeded() + await contentStore?.waitForCustomResourceTaskForTesting(for: key) + let resourceHost = try #require( + regularContent.children.first as? CustomTabResourceViewController + ) + #expect(resourceHost.readyViewControllerForTesting === customViewController) + #expect(customViewController.parent === resourceHost) + + contentStore = nil + + #expect(retainedStore == nil) + #expect(customViewController.parent == nil) + } + + @Test + func customTabDisplayItemDoesNotCollideWithInternalDOMElementIdentifier() async throws { let customViewController = UIViewController() let customTab = WebInspectorTab( id: WebInspectorTab.DisplayItem.domElementID, @@ -507,6 +868,7 @@ struct ParentContainerTests { customViewController } let session = WebInspectorSession(tabs: [.dom, customTab]) + let contentStore = PresentationContentStore() let projection = WebInspectorTab.DisplayProjection() let compactDisplayItems = projection.displayItems(for: .compact, tabs: session.interface.tabs) let displayItemIDs = compactDisplayItems.map(\.id) @@ -531,17 +893,22 @@ struct ParentContainerTests { #expect(session.interface.resolvedSelection(for: .compact) == .customTab(customTab.id)) #expect(session.interface.selectedTab == customTab) - let customContent = WebInspectorTab.ContentFactory.makeViewController( - for: .customTab(customTab.id), - session: session, - hostLayout: .compact, - tabs: session.interface.tabs + let customHost = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .customTab(customTab.id), + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? CustomTabResourceViewController ) - #expect(customContent === customViewController) + await contentStore.waitForCustomResourceTaskForTesting( + for: customContentKey(for: customTab.id) + ) + #expect(customHost.readyViewControllerForTesting === customViewController) } @Test - func regularCustomTabWrapsNavigationControllerContent() throws { + func regularCustomTabWrapsNavigationControllerContent() async throws { let customRootViewController = UIViewController() let customNavigationController = UINavigationController(rootViewController: customRootViewController) let customTab = WebInspectorTab( @@ -552,15 +919,26 @@ struct ParentContainerTests { customNavigationController } let session = WebInspectorSession(tabs: [customTab]) - let host = RegularTabContentViewController(session: session) + let contentStore = PresentationContentStore() + let host = RegularTabContentViewController( + session: session, + contentStore: contentStore + ) host.loadViewIfNeeded() let installedRoot = try #require(host.viewControllers.first) installedRoot.loadViewIfNeeded() + await contentStore.waitForCustomResourceTaskForTesting( + for: customContentKey(for: customTab.id) + ) + let resourceHost = try #require( + installedRoot.children.first as? CustomTabResourceViewController + ) #expect(installedRoot !== customNavigationController) #expect(installedRoot is UINavigationController == false) - #expect(customNavigationController.parent === installedRoot) + #expect(resourceHost.readyViewControllerForTesting === customNavigationController) + #expect(customNavigationController.parent === resourceHost) } @Test @@ -573,8 +951,12 @@ struct ParentContainerTests { UIViewController() } let session = WebInspectorSession(tabs: [.dom, customTab]) + let contentStore = PresentationContentStore() - let compactHost = CompactTabBarController(session: session) + let compactHost = CompactTabBarController( + session: session, + contentStore: contentStore + ) #expect( compactHost.displayedTabIdentifiersForTesting == [ @@ -584,7 +966,10 @@ struct ParentContainerTests { ] ) - let regularHost = RegularTabContentViewController(session: session) + let regularHost = RegularTabContentViewController( + session: session, + contentStore: contentStore + ) regularHost.loadViewIfNeeded() let segmentedControl = regularHost.segmentedControlForTesting @@ -626,79 +1011,105 @@ struct ParentContainerTests { @Test func rootPresentationFallbacksDetachOnlyOnce() async throws { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting _ = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) viewController.loadViewIfNeeded() - #expect(session.interface.contentCacheCountForTesting > 0) - let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision + #expect(contentStore.contentCountForTesting > 0) + let stableModel = session.model + let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting viewController.finishRootPresentationLifecycleForTesting() - #expect(await waitUntilDetachCount(1, in: session)) viewController.finishRootPresentationLifecycleForTesting() + #expect( + await viewController.waitForRootPresentationRetirementTaskCompletionForTesting( + after: retirementBaseline + ) + ) #expect(session.detachCountForTesting == 1) - #expect(session.interface.contentCacheCountForTesting == 0) - #expect(session.interface.contextBoundContentRevision > contentRevisionBeforeRetirement) + #expect(contentStore.contentCountForTesting == 0) + #expect(session.model === stableModel) + #expect(session.model.state == .detached) } @Test func hiddenNavigationControllerRemovalFinishesRootPresentationLifecycle() async throws { let tab = makeNoOpTab(id: "webinspector_test_lifecycle_hidden") let session = makeSessionWithNoOpAttachment(tabs: [tab]) + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting + let key = customContentKey(for: tab.id) _ = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(tab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) + await contentStore.waitForCustomResourceTaskForTesting(for: key) let navigationController = UINavigationController(rootViewController: viewController) let window = showInWindow(navigationController) defer { window.isHidden = true } #expect(navigationController.view.window === window) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.customResourceStatusForTesting(for: key) == .ready) let coveringViewController = UIViewController() navigationController.pushViewController(coveringViewController, animated: false) #expect(navigationController.topViewController === coveringViewController) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.customResourceStatusForTesting(for: key) == .ready) + let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting window.rootViewController = UIViewController() window.layoutIfNeeded() #expect(navigationController.view.window == nil) - #expect(await waitUntilDetachCount(1, in: session)) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect( + await viewController.waitForRootPresentationRetirementTaskCompletionForTesting( + after: retirementBaseline + ) + ) + #expect(session.detachCountForTesting == 1) + #expect(contentStore.customResourceStatusForTesting(for: key) == nil) } @Test func directWindowRootRemovalFinishesRootPresentationLifecycle() async throws { let tab = makeNoOpTab(id: "webinspector_test_lifecycle_direct") let session = makeSessionWithNoOpAttachment(tabs: [tab]) + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting + let key = customContentKey(for: tab.id) _ = WebInspectorTab.ContentFactory.makeViewController( for: .customTab(tab.id), session: session, - hostLayout: .compact, - tabs: session.interface.tabs + contentStore: contentStore, + hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) + await contentStore.waitForCustomResourceTaskForTesting(for: key) let window = showInWindow(viewController) defer { window.isHidden = true } #expect(viewController.view.window === window) - #expect(session.interface.contentCacheCountForTesting > 0) + #expect(contentStore.customResourceStatusForTesting(for: key) == .ready) + let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting window.rootViewController = UIViewController() window.layoutIfNeeded() #expect(viewController.view.window == nil) - #expect(await waitUntilDetachCount(1, in: session)) - #expect(session.interface.contentCacheCountForTesting == 0) + #expect( + await viewController.waitForRootPresentationRetirementTaskCompletionForTesting( + after: retirementBaseline + ) + ) + #expect(session.detachCountForTesting == 1) + #expect(contentStore.customResourceStatusForTesting(for: key) == nil) } @Test @@ -723,20 +1134,22 @@ struct ParentContainerTests { @Test func interactiveDismissCancelDoesNotDetachOrDropContentCache() async throws { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting _ = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .compact ) - let viewController = WebInspectorViewController(session: session) viewController.loadViewIfNeeded() - let cacheCountBeforeCancel = session.interface.contentCacheCountForTesting + let cacheCountBeforeCancel = contentStore.contentCountForTesting viewController.finishRootPresentationLifecycleForTesting(cancelled: true) #expect(session.detachCountForTesting == 0) #expect(viewController.hasFinishedRootPresentationLifecycleForTesting == false) - #expect(session.interface.contentCacheCountForTesting == cacheCountBeforeCancel) + #expect(contentStore.contentCountForTesting == cacheCountBeforeCancel) } @Test @@ -763,29 +1176,32 @@ struct ParentContainerTests { @Test func rootDismissDropsContentCacheWithoutAutomaticDetach() async throws { let session = makeSessionWithNoOpAttachment() + let viewController = WebInspectorViewController(session: session) + let contentStore = viewController.presentationContentStoreForTesting _ = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) _ = WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .regular ) - let viewController = WebInspectorViewController(session: session) viewController.automaticallyDetachesOnDismiss = false viewController.loadViewIfNeeded() - #expect(session.interface.contentCacheCountForTesting > 0) - let contentRevisionBeforeRetirement = session.interface.contextBoundContentRevision + #expect(contentStore.contentCountForTesting > 0) + let stableModel = session.model let retirementBaseline = viewController.rootPresentationRetirementTaskCompletionCountForTesting viewController.finishRootPresentationLifecycleForTesting() #expect(await viewController.waitForRootPresentationRetirementTaskCompletionForTesting(after: retirementBaseline)) #expect(session.detachCountForTesting == 0) - #expect(session.interface.contentCacheCountForTesting == 0) - #expect(session.interface.contextBoundContentRevision == contentRevisionBeforeRetirement) + #expect(contentStore.contentCountForTesting == 0) + #expect(session.model === stableModel) } @Test @@ -813,7 +1229,10 @@ struct ParentContainerTests { @Test func compactHostDisplaysDOMElementAndNetworkTabs() { let session = WebInspectorSession() - let host = CompactTabBarController(session: session) + let host = CompactTabBarController( + session: session, + contentStore: PresentationContentStore() + ) #expect( host.displayedTabIdentifiersForTesting @@ -822,12 +1241,14 @@ struct ParentContainerTests { } @Test - func compactFactoryUsesDomainNavigationControllers() throws { + func compactFactoryUsesDomainNavigationControllers() async throws { let session = WebInspectorSession(context: makeContext()) + let contentStore = PresentationContentStore() let domViewController = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) let domNavigationController = try #require(domViewController as? DOMCompactNavigationController) @@ -836,24 +1257,35 @@ struct ParentContainerTests { let elementViewController = WebInspectorTab.ContentFactory.makeViewController( for: .domElement(parent: WebInspectorTab.dom.id), session: session, + contentStore: contentStore, hostLayout: .compact ) let elementNavigationController = try #require(elementViewController as? DOMCompactNavigationController) #expect(elementNavigationController.viewControllers.first is DOMElementViewController) - let networkViewController = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - hostLayout: .compact + let networkResourceViewController = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .network, + session: session, + contentStore: contentStore, + hostLayout: .compact + ) as? NetworkTabResourceViewController + ) + await contentStore.waitForNetworkResourceTaskForTesting() + let networkNavigationController = try #require( + networkResourceViewController.readyViewControllerForTesting + as? NetworkCompactNavigationController ) - let networkNavigationController = try #require(networkViewController as? NetworkCompactNavigationController) #expect(networkNavigationController.viewControllers.first is NetworkListViewController) } @Test func regularHostWrapsDomainSplitControllersBeforeInstallingInNavigationStack() throws { let session = WebInspectorSession() - let host = RegularTabContentViewController(session: session) + let host = RegularTabContentViewController( + session: session, + contentStore: PresentationContentStore() + ) host.loadViewIfNeeded() @@ -873,9 +1305,11 @@ struct ParentContainerTests { @Test func cachedDOMTreeControllerIsSharedAcrossCompactAndRegularHosts() throws { let session = WebInspectorSession() + let contentStore = PresentationContentStore() let compactViewController = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .compact ) let compactNavigationController = try #require(compactViewController as? DOMCompactNavigationController) @@ -886,6 +1320,7 @@ struct ParentContainerTests { let regularRoot = WebInspectorTab.ContentFactory.makeViewController( for: .dom, session: session, + contentStore: contentStore, hostLayout: .regular ) regularRoot.loadViewIfNeeded() @@ -908,19 +1343,26 @@ struct ParentContainerTests { func networkPanelModelSelectionIsSharedAcrossParentHosts() async throws { let context = makeContext() let session = WebInspectorSession(context: context) + let contentStore = PresentationContentStore() let requestID = await applyRequest( to: context, requestID: "1", url: "https://example.com/app.js" ) - let request = try #require(context.registeredRequest(for: requestID)) - let model = session.interface.networkPanelModel(for: context) - let compactNavigationController = try #require( + let request = try #require(try context.networkRequest(id: requestID)) + let compactResourceViewController = try #require( WebInspectorTab.ContentFactory.makeViewController( for: .network, session: session, + contentStore: contentStore, hostLayout: .compact - ) as? NetworkCompactNavigationController + ) as? NetworkTabResourceViewController + ) + await contentStore.waitForNetworkResourceTaskForTesting() + let model = try #require(contentStore.networkPanelModelForTesting) + let compactNavigationController = try #require( + compactResourceViewController.readyViewControllerForTesting + as? NetworkCompactNavigationController ) let window = showInWindow(compactNavigationController, useUIKitVisibility: false) defer { window.isHidden = true } @@ -932,10 +1374,17 @@ struct ParentContainerTests { } #expect(didPushDetail) - let regularRoot = WebInspectorTab.ContentFactory.makeViewController( - for: .network, - session: session, - hostLayout: .regular + let regularResourceViewController = try #require( + WebInspectorTab.ContentFactory.makeViewController( + for: .network, + session: session, + contentStore: contentStore, + hostLayout: .regular + ) as? NetworkTabResourceViewController + ) + await contentStore.waitForNetworkResourceTaskForTesting() + let regularRoot = try #require( + regularResourceViewController.readyViewControllerForTesting ) regularRoot.loadViewIfNeeded() let splitViewController = try childViewController( @@ -985,13 +1434,13 @@ struct ParentContainerTests { } } - private func makeContext() -> WebInspectorContext { - WebInspectorContext.preview(isolation: MainActor.shared) + private func makeContext() -> WebInspectorModelContext { + WebInspectorModelContext.preview() } @discardableResult private func applyRequest( - to context: WebInspectorContext, + to context: WebInspectorModelContext, requestID rawRequestID: String, url: String ) async -> WebInspectorDataKit.NetworkRequest.ID { @@ -1076,9 +1525,9 @@ struct ParentContainerTests { } } - private func makeFakeContainer() async throws -> WebInspectorContainer { + private func makeFakeProxy() async throws -> WebInspectorProxy { let runtime = try await WebInspectorProxyTestRuntime.start() - return WebInspectorContainer(proxy: runtime.proxy) + return runtime.proxy } private func attach( @@ -1089,9 +1538,9 @@ struct ParentContainerTests { ) -> (any WebInspectorPageUserInterfaceStyleObserving)? = { _ in nil } ) async throws { try await session.attachForTesting( - makeContainer: { [self] in + makeProxy: { [self] in try await attachAction() - return try await makeFakeContainer() + return try await makeFakeProxy() }, makePageUserInterfaceStyleObserver: makePageUserInterfaceStyleObserver ) @@ -1103,6 +1552,17 @@ struct ParentContainerTests { WebInspectorSession(context: makeContext(), tabs: tabs) } + private func makeAttachmentSession( + tabs: [WebInspectorTab] = [.dom, .network] + ) -> WebInspectorSession { + WebInspectorSession( + context: WebInspectorModelContext.preview( + configuration: .init(domains: []) + ), + tabs: tabs + ) + } + private func makeNoOpTab( id: WebInspectorTab.ID = "webinspector_test_noop", title: String = "Test" @@ -1112,6 +1572,12 @@ struct ParentContainerTests { } } + private func customContentKey( + for tabID: WebInspectorTab.ID + ) -> WebInspectorTab.ContentKey { + WebInspectorTab.ContentKey(tabID: tabID, contentID: "root") + } + @MainActor private final class PageUserInterfaceStyleObserverRecorder { private let styleOnStart: UIUserInterfaceStyle @@ -1133,6 +1599,21 @@ struct ParentContainerTests { } } + @MainActor + private final class SessionRetainingViewController: UIViewController { + let session: WebInspectorSession + + init(session: WebInspectorSession) { + self.session = session + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + } + @MainActor private final class PageUserInterfaceStyleObserverDouble: WebInspectorPageUserInterfaceStyleObserving { private let styleOnStart: UIUserInterfaceStyle diff --git a/Tests/WebInspectorUITests/UITestObservationWaits.swift b/Tests/WebInspectorUITests/UITestObservationWaits.swift index bd4838da..80d33123 100644 --- a/Tests/WebInspectorUITests/UITestObservationWaits.swift +++ b/Tests/WebInspectorUITests/UITestObservationWaits.swift @@ -1,4 +1,3 @@ -#if canImport(UIKit) import ObservationBridge import WebInspectorDataKit @@ -111,4 +110,3 @@ final class UITestDeinitProbe { waiter.continuation.resume(returning: result) } } -#endif